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..235868f6155 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -72,6 +72,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: >- pnpm vitest run + 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.test.mjs 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/.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/README.md b/README.md index ee5cf6a0a78..095b0f486e9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- 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 30940476103..c2cbd6e62d0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-09", + "updatedAt": "2026-07-11", "policy": { "maturityLevels": [ "experimental", @@ -390,6 +390,109 @@ ], "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", @@ -1221,6 +1324,9 @@ "surfaces": [ "daemon stream", "main PTY batching", + "runtime path provenance", + "runtime serve-sim detection", + "runtime terminal wait detection", "renderer ACK", "xterm scheduler", "hidden output" @@ -1240,20 +1346,27 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the main-process pending-output caps that exist on main@1282f5c2d. 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.", + "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": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", + "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ - "src/main/ipc/pty.test.ts" + "src/main/ipc/pty.test.ts", + "src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "src/main/emulator/serve-sim-state-watcher.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "assertionRefs": [ { @@ -1263,6 +1376,37 @@ "active PTY pending output is prioritized during renderer backpressure", "combined pending output exceeding the interactive size limit is batched" ] + }, + { + "file": "src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "assertions": [ + "reuses path-candidate history across repeated pathless PTY output", + "copies history when new output adds a path candidate" + ] + }, + { + "file": "src/main/emulator/serve-sim-state-watcher.test.ts", + "assertions": [ + "does not buffer repeated brace-free PTY output while waiting for metadata", + "detects serve-sim metadata split across PTY chunks and releases the partial object" + ] + }, + { + "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", + "assertions": [ + "does not rebuild or repeatedly scan an ordinary saturated tail", + "memoized stamping matches recompute reference: blocked prompt split across chunks", + "stays equivalent across tail eviction beyond the retained cap" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "returns a blocked wait result for Codex update prompts", + "returns a blocked wait result for Codex workspace trust prompts", + "returns a blocked wait result for generic Codex interactive prompts", + "resolves tui-idle when a stale Codex prompt is followed by the ready header" + ] } ], "evidenceRuns": [ @@ -1274,6 +1418,33 @@ "result": "passed", "durationSeconds": 1.5, "summary": "1 test file(s) passed, 209 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "result": "passed", + "durationSeconds": 3.49, + "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", + "result": "passed", + "durationSeconds": 0.37, + "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", + "result": "passed", + "durationSeconds": 21.33, + "summary": "2 test files passed, 609 tests passed on pushed commit 4fb14eac3897." } ], "runtimeBudget": { @@ -1286,11 +1457,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Existing renderer tests cover replay/backlog slices. Needs intentional-break proof and broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, - "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios." + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates." }, "promotionCriteria": [ "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", @@ -1299,6 +1470,9 @@ ], "knownGaps": [ "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", + "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", + "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." ], @@ -1791,6 +1965,423 @@ ], "demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence." }, + { + "id": "terminal-input.windows-modified-enter-routing", + "title": "Windows modified Enter routes agent-compatible bytes to the active pane", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "main-provider-renderer-unit-and-windows-electron", + "surfaces": [ + "terminal input", + "Windows modified Enter", + "agent identity", + "split panes", + "SSH", + "WSL", + "remote runtime" + ], + "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.", + "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.", + "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" + ], + "testFiles": [ + "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", + "tests/e2e/terminal-shortcuts.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/process-table-snapshot.test.ts", + "assertions": [ + "a fresh request bypasses a same-tick TTL cache and any scan already in flight before the request", + "same-turn fresh requests and ordinary cache misses share one later scan while requests arriving after it starts queue behind it" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "successful root-present Windows enumeration and failed, blank, malformed, or root-missing enumeration remain distinct evidence states", + "an observed shell root with no children is authoritative while a snapshot omitting that root is unavailable", + "an attached Droid candidate is authorized only when exact ConPTY membership contains it, while a detached Start-Process descendant is rejected", + "a Windows process-table scan with no recognized agent performs zero ConPTY membership helper forks", + "fresh POSIX scan failure or a snapshot missing the PTY root is unavailable rather than shell proof", + "the compatibility process-name API still preserves its existing fallback contract for other consumers" + ] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "the fixed node-pty helper returns validated integer console membership containing the PTY root", + "root-only fallback, malformed/missing-root messages, spawn errors, and a silent helper timeout all fail closed", + "the bounded timeout kills the child helper exactly once" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "a delayed foreground confirmation is discarded after the owning PTY exits" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "new daemon sessions and warm reattach results carry the same minimal allowlisted launchAgent" + ] + }, + { + "file": "src/main/daemon/daemon-server.test.ts", + "assertions": [ + "daemon protocol v21 persists allowlisted launchAgent across reattach and drops unknown identity strings" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "Windows daemon foreground lookup can return PowerShell synchronously before async process-tree enrichment resolves the agent", + "the enriched agent result is served by a later foreground read", + "fresh confirmation awaits a post-request scan instead of serving cached PowerShell and returns null when inspection is unavailable" + ] + }, + { + "file": "tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "assertions": [ + "the real daemon handle and pane tracker keep restored Droid identity when confirmation resolves after the old cache window", + "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/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" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts", + "assertions": [ + "only routing-trusted current process identity overrides launch evidence and confirmed shell foreground clears stale identity", + "a newer untrusted or neutral command generation blocks stale launch ownership until confirmation", + "only allowlisted fresh process or pane-scoped launch ownership selects Droid's CSI-u capability", + "forged hook or OSC-derived Droid status cannot change the Windows byte encoding", + "after split and original-pane close, a sole surviving sibling cannot inherit the original pane's Droid launch identity" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", + "assertions": [ + "manual and restored local agent identity is published from bounded foreground-process reads", + "a delayed read cannot publish after the pane rebinds to a replacement PTY id", + "every new command publishes a routing-neutral generation before confirmation", + "typed agent text only widens the confirmation window and never becomes published or routable identity", + "unavailable typed-command confirmation stays on legacy routing through the three-read ladder and then fails closed", + "ordinary fast OSC 133;C-to-D command pairs publish shell without a fresh confirmation scan", + "command-start and visible launch recovery use at most three reads across the bounded 350 ms, 1.2 s, and 6 s ladder", + "null and rejected reads participate in the same bounded known-identity ladder", + "command-finished accepts one fresh recognized shell scan when agent evidence exists, while unavailable final evidence retires routing without claiming shell" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "after detach remount, pane-key launch/process identity forces a leaked nested OSC 133;D through foreground confirmation", + "a still-running Droid process republishes Droid instead of being overwritten with shell-foreground evidence", + "typing droid does not route CSI-u until fresh process evidence confirms Droid", + "null and rejected command-start reads never promote typed Droid text to process evidence", + "typed Droid input cannot replace live Antigravity process or pane-scoped launch identity inside another TUI", + "typed Droid input on SSH or remote-runtime panes never writes local process evidence", + "Droid routing survives a fresh command-finish scan that still sees Droid and retires after one fresh shell result", + "a D-to-C transition immediately routes legacy bytes while the new command generation is pending", + "an identityless visible-read versus OSC 133;D race cancels stale sampling and fails closed to shell", + "a manually typed Droid in a shell without OSC 133 gains routing only after accepted-input fresh confirmation", + "accepted no-OSC exit or interrupt input immediately revokes trusted Droid bytes and confirms the returned shell", + "unavailable command-finish evidence resolves deferred cleanup, returns legacy routing, and does not assert shell foreground", + "daemon-returned allowlisted launchAgent requests warm-reattach confirmation without restoring stale byte authority or the original args/env payload, while missing identity fails closed", + "an unknown persisted wrapper cannot spoof launch identity and confirmed shell state prevents repeat focus scans" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "allowlisted daemon launchAgent reaches PtyConnectResult on reattach", + "unknown launch identity is dropped at the renderer transport boundary" + ] + }, + { + "file": "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", + "assertions": [ + "pane-to-tab detach moves only the detached leaf's launch and process identity to its new pane key", + "pane-to-tab detach preserves the source PTY shell override across native PowerShell and WSL classifications", + "the source hook snapshot is retired rather than copied to a key the running PTY cannot emit", + "the detached Droid keeps CSI-u while the source sibling keeps its own legacy encoding" + ] + }, + { + "file": "src/shared/agent-process-recognition.test.ts", + "assertions": [ + "Droid's process name resolves to the allowlisted Droid agent identity without substring matching" + ] + }, + { + "file": "tests/e2e/terminal-shortcuts.spec.ts", + "assertions": [ + "on Windows the focused terminal sends CSI-u bytes for a Droid pane and Esc+CR bytes after switching that same pane to Antigravity identity", + "the byte oracle observes the real renderer-to-main PTY write path rather than terminal DOM state" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "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", + "result": "passed", + "durationSeconds": 8.71, + "summary": "Twenty-two focused test files passed (1206 tests) on current main, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused renderer units plus the Windows Electron terminal-shortcut spec" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic units are stable locally; the Windows-only Electron byte path needs CI or nightly soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Reverting to the previous unconditional Windows Esc+CR branch fails the Droid byte assertion, while resolving launch metadata from any key other than the active pane fails the split-survivor isolation assertion. Saved intentional-break artifacts and live Windows history are still missing." + }, + "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." + }, + "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.", + "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.", + "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.", + "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", @@ -2413,6 +3004,151 @@ ], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-query.mobile-view-authority", + "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "mobile-runtime-rpc-contract", + "surfaces": [ + "mobile terminal WebView", + "terminal query authority", + "runtime RPC", + "mobile subscription replay", + "multi-mobile input floor" + ], + "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": [ + "https://github.com/stablyai/orca/issues/8128", + "https://github.com/stablyai/orca/pull/8206" + ], + "invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.", + "oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.", + "commands": [ + "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "mobile/src/terminal/terminal-webview-query-reply-routing.test.ts", + "mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/mobile-presence-lock.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/terminal-reply-query-scan.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "assertions": [ + "snapshot replay emits no terminal-data message before the replay boundary", + "a live query after the boundary emits exactly one terminal-data message", + "a superseded terminal generation cannot regain reply authority", + "xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "only the elected mobile subscriber can write a claimed query reply", + "valid replies skip mobileTookFloor", + "ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "live terminal data and view authority register before asynchronous mobile fit", + "a query received during that await is retained and emitted after the snapshot" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "desktop xterm onData replies are dropped while mobile owns the terminal driver", + "desktop capability-handler replies outside onData honor the same mobile authority lock" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "mobile and runtime deterministic unit gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue." + }, + "promotionCriteria": [ + "Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.", + "Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.", + "Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "knownGaps": [ + "No live iOS or Android WebView run is registered.", + "No real SSH, WSL, daemon, or two-device provider-contract run is registered.", + "Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.", + "A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands." + ], + "demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use." + }, { "id": "terminal-runtime.mobile-stream-budget", "title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded", 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-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs new file mode 100644 index 00000000000..c8478f3aee0 --- /dev/null +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -0,0 +1,56 @@ +#!/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') { + process.exit(0) +} + +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..e5bcbaa557b --- /dev/null +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -0,0 +1,69 @@ +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 projectRoot = resolve(import.meta.dirname, '../..') + +describe('Windows CLI launcher', () => { + 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/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..82b408a336f 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,41 @@ 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('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/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/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 9f71e6e7900..f4fb2195223 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -10,6 +10,7 @@ "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", + "../src/main/ipc/worktree-path-comparison.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index c6cde5794e1..2cb4aef80ae 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.4m + + downloads: 4.8m @@ -15,7 +15,7 @@ downloads downloads - 4.4m - 4.4m + 4.8m + 4.8m 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/readme/README.es.md b/docs/readme/README.es.md index 08c63240cc6..f8d168e5f62 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b62d2fd2caa..6c5e1bbfff5 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index dc4bc8355fe..ce9a13cc30b 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 5d268491ef0..d81c6077879 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 497e98fe919..c771506ed7f 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md new file mode 100644 index 00000000000..ac3249d306e --- /dev/null +++ b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md @@ -0,0 +1,131 @@ +# SSH Repo Host Reconciliation Design + +Date: 2026-07-10 + +## Problem + +The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the +removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to +the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached +old-target row alongside the fetched new-target row because repo rows are keyed by execution host +and repo ID. + +The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made +worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row +without weakening that boundary. + +## Why UUID Inference Is Unsafe + +The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a +checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it +must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID +does not prove that the SSH row moved. + +Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed +target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target +to the new target. The renderer must consume this evidence instead of inferring migration from live +siblings or SSH target metadata. + +## Ordering Gap + +Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is +possible in renderer state: + +1. The catalog transaction can merge old and new rows before the add response supplies migration + evidence. +2. The add response can supply evidence while renderer state still contains only the old row. + +Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that +point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and +moves cached worktree ownership onto the new host. + +## Non-Goals + +- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs. +- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings. +- Do not remove PR #7997's execution-host context from destructive operations. +- Do not change git-provider behavior or add provider-specific assumptions. + +## Design + +1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count. +2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation. +3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main + still sends `repos:changed` when at least one repo moved. +4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the + invoke resolves. The web preload returns an empty evidence list because target management is + desktop-only. +5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local, + targeted-runtime, and all-host catalog transaction reconciles against the latest pending set. +6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old + direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are + not considered evidence. +7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns + local and direct-SSH setups; `runtime:` setups remain authoritative on the remote + Orca server. +8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH + host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate + is discarded. +9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists. + Repo catalog transactions also reapply pending worktree migration, covering responses that land + after evidence arrives but before the new repo row. + +```text +main: remove target -> tombstone +main: re-add matching identity -> reassign old target ID to new target ID +main: return exact (old target, new target, moved repo IDs) +renderer: retain evidence until catalog includes the exact new SSH owner +renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership +``` + +## Safety Constraints + +- A local or runtime sibling with the same UUID never supersedes an SSH ghost. +- A mapping is not consumed until the exact new direct-SSH row exists. +- Runtime-owned SSH rows and `runtime:` setup rows remain untouched. +- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest + repos and pending evidence. +- Missing, offline, or unhydrated target metadata is not deletion or migration evidence. + +## Test Plan + +- Persistence returns exact migrated repo IDs and leaves other hosts untouched. +- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import. +- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence. +- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime + ownership preservation, and evidence deduplication. +- Store tests cover both event orders: catalog first and evidence first. +- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID. +- Old direct-SSH setups are removed only for proven migrations; runtime setups remain. +- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only + worktree row migrates to the exact new host. +- A deferred old-host worktree response cannot restore old visible or detected ownership after the + new repo catalog consumes the migration evidence. +- Targeted runtime and all-host catalog transactions reconcile against the latest state. + +Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required +by the repository. + +## Electron Validation + +Use an isolated profile and a throwaway Linux SSH target: + +1. Add a repo through the throwaway SSH target. +2. Remove the SSH target and re-add the same identity without restarting the renderer. +3. Confirm exactly one SSH project row remains and its worktree opens. +4. Confirm Explorer lists the remote files without an ambiguous-host routing error. +5. Repeat the cycle and confirm the stale row does not return. +6. Keep an adjacent local project visible and confirm it remains unchanged. + +There is no new control or copy. The behavioral evidence is the stable row, successful workspace +routing, and deterministic store assertions. + +## Performance And Scope + +Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID +once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners, +subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually +migrated during the user-triggered add/import operation. + +Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the +existing strict alias or host/user/port logic, and git-provider identity is not used. 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-query-authority.md b/docs/reference/terminal-query-authority.md index e05b5768836..bb6b11fea8c 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time): stream (CLI `terminal.read`, automation observers) do not suppress — they also do not answer; that bounded no-reply case matches today's behavior. +Mobile streams preserve that singularity across snapshot startup and multiple +views. The mobile WebView suppresses `onData` during snapshot replay, then +enables replies at an explicit live-output boundary. Clearing the WebView drops +that replay queue and resumes live reply authority immediately. If a live query +arrived while the initial snapshot was being built and its output sequence is +covered by that snapshot, runtime RPC re-emits only the bounded query sequence +after the snapshot; ordinary covered output stays deduplicated. `terminal.send` +accepts the resulting `inputKind: query-reply` only from the earliest active +phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest +is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not +change reply ownership; passive desktop-mode watchers are excluded. Peer phones +are rejected, the next subscriber is promoted on unsubscribe, and desktop +parser and capability handlers stay silent until desktop retakes the driver. +Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile +client never forwards replies to a host that lacks it, because such hosts +strip `inputKind` and would treat the bytes as floor-taking shell input. +Residual: a desktop→mobile driver handoff has a bounded double-reply window +until the async driver-change event reaches the desktop renderer's cached +driver map (`isPtyLocked`). + Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes triggered by option mutations). ## Reply classes -| Class | Queries | Answer source | -| --- | --- | --- | -| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | -| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | -| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | -| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | -| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | -| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | +| Class | Queries | Answer source | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | ### View-attribute bridge @@ -237,7 +257,8 @@ any spawn-window drops). the documented ConPTY DA1 variant and the view-attribute parser handlers the headless core cannot serve. 6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. + subscriber is attached. When multiple desktop/mobile views coexist, the + terminal driver and server-side mobile election keep one reply writer. **Contract amendment** — `terminal-model-view-contract.md` invariant 6 is replaced by: diff --git a/mobile/app.json b/mobile/app.json index db8326a3dbc..e2d6a248f85 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.26", + "version": "0.0.27", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 6 + "versionCode": 7 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d613bff4575..01a3cc95acd 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,5 +1,13 @@ 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 +} from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router' import { @@ -20,7 +28,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, @@ -684,13 +693,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( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 18a393cae28..5737683d481 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -112,6 +112,8 @@ import { import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' +import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, @@ -2424,10 +2426,13 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.clear() }, [connState]) + const hostQueryReplyInputSupportedRef = useRef(false) + useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + hostQueryReplyInputSupportedRef.current = false return } let stale = false @@ -2444,11 +2449,16 @@ export default function SessionScreen() { setAgentSessionHistorySupported( status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true ) + // Why: hosts without this capability strip inputKind from terminal.send, + // so a forwarded xterm reply would become floor-stealing shell input. + hostQueryReplyInputSupportedRef.current = + status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) setAgentSessionHistorySupported(false) + hostQueryReplyInputSupportedRef.current = false } }) return () => { @@ -2518,6 +2528,7 @@ export default function SessionScreen() { } }, []) + const pendingForegroundRecoveryRef = useRef(false) useEffect(() => { let previousAppState: AppStateStatus | null = AppState.currentState const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { @@ -2536,7 +2547,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, @@ -2545,12 +2556,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. @@ -3036,10 +3071,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) } })() @@ -3531,6 +3571,18 @@ export default function SessionScreen() { [allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput] ) + const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => { + void sendMobileTerminalQueryReply({ + bytes, + client: clientRef.current, + clientId: deviceTokenRef.current, + connected: connStateRef.current === 'connected', + handle, + hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current, + subscribedTerminals: terminalUnsubsRef.current + }) + }, []) + async function handleClearTerminal(target: Terminal) { if (!client) { return @@ -3915,6 +3967,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) => { @@ -3969,6 +4026,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) } @@ -4800,6 +4860,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalQueryReply={handleTerminalQueryReply} onTerminalTap={handleTerminalTap} onFileTap={handleFileTap} onOpenUrl={handleTerminalOpenUrl} 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/package.json b/mobile/package.json index 9f91777a842..66abfe67e5f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -35,6 +35,7 @@ "expo-haptics": "^55.0.14", "expo-image-manipulator": "^55.0.17", "expo-image-picker": "^55.0.20", + "expo-keep-awake": "~55.0.8", "expo-linking": "^55.0.15", "expo-modules-core": "~55.0.25", "expo-network": "~55.0.14", @@ -73,7 +74,7 @@ "oxlint": "^1.71.0", "react-test-renderer": "19.2.6", "tsx": "^4.22.4", - "typescript": "^7.0.2", + "typescript": "6.0.3", "vite": "^8.0.16", "vitest": "^4.1.9" }, diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 496855fc266..224511d38c5 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 6.0.3 expo: specifier: ^55.0.27 - version: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + version: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-build-properties: specifier: ^55.0.13 version: 55.0.13(expo@55.0.27) @@ -65,6 +65,9 @@ importers: expo-image-picker: specifier: ^55.0.20 version: 55.0.20(expo@55.0.27) + expo-keep-awake: + specifier: ~55.0.8 + version: 55.0.8(expo@55.0.27)(react@19.2.6) expo-linking: specifier: ^55.0.15 version: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -76,7 +79,7 @@ importers: version: 55.0.14(expo@55.0.27)(react@19.2.6) expo-notifications: specifier: ^55.0.22 - version: 55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + version: 55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-router: specifier: ^55.0.14 version: 55.0.14(7ffd97bbc113b875771c40f21f44415d) @@ -85,7 +88,7 @@ importers: version: 55.0.13(expo@55.0.27) expo-splash-screen: specifier: ^55.0.20 - version: 55.0.20(expo@55.0.27)(typescript@7.0.2) + version: 55.0.20(expo@55.0.27)(typescript@6.0.3) expo-status-bar: specifier: ^55.0.6 version: 55.0.6(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -175,8 +178,8 @@ importers: specifier: ^4.22.4 version: 4.22.4 typescript: - specifier: ^7.0.2 - version: 7.0.2 + specifier: 6.0.3 + version: 6.0.3 vite: specifier: ^8.0.16 version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) @@ -2793,126 +2796,6 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/typescript-aix-ppc64@7.0.2': - resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [aix] - - '@typescript/typescript-darwin-arm64@7.0.2': - resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/typescript-darwin-x64@7.0.2': - resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/typescript-freebsd-arm64@7.0.2': - resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [freebsd] - - '@typescript/typescript-freebsd-x64@7.0.2': - resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [freebsd] - - '@typescript/typescript-linux-arm64@7.0.2': - resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/typescript-linux-arm@7.0.2': - resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/typescript-linux-loong64@7.0.2': - resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} - engines: {node: '>=16.20.0'} - cpu: [loong64] - os: [linux] - - '@typescript/typescript-linux-mips64el@7.0.2': - resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} - engines: {node: '>=16.20.0'} - cpu: [mips64el] - os: [linux] - - '@typescript/typescript-linux-ppc64@7.0.2': - resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [linux] - - '@typescript/typescript-linux-riscv64@7.0.2': - resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} - engines: {node: '>=16.20.0'} - cpu: [riscv64] - os: [linux] - - '@typescript/typescript-linux-s390x@7.0.2': - resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} - engines: {node: '>=16.20.0'} - cpu: [s390x] - os: [linux] - - '@typescript/typescript-linux-x64@7.0.2': - resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/typescript-netbsd-arm64@7.0.2': - resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [netbsd] - - '@typescript/typescript-netbsd-x64@7.0.2': - resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [netbsd] - - '@typescript/typescript-openbsd-arm64@7.0.2': - resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [openbsd] - - '@typescript/typescript-openbsd-x64@7.0.2': - resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [openbsd] - - '@typescript/typescript-sunos-x64@7.0.2': - resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [sunos] - - '@typescript/typescript-win32-arm64@7.0.2': - resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/typescript-win32-x64@7.0.2': - resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -6376,9 +6259,9 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@7.0.2: - resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} - engines: {node: '>=16.20.0'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} hasBin: true ua-parser-js@1.0.41: @@ -8230,23 +8113,23 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.34': {} - '@expo/cli@55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2)': + '@expo/cli@55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config': 55.0.18(typescript@6.0.3) '@expo/config-plugins': 55.0.10 '@expo/devcert': 1.2.1 '@expo/env': 2.1.2 - '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/image-utils': 0.8.14(typescript@6.0.3) '@expo/json-file': 10.2.0 '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro': 55.1.1 - '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2) + '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@6.0.3) '@expo/osascript': 2.7.0 '@expo/package-manager': 1.13.0 '@expo/plist': 0.5.4 - '@expo/prebuild-config': 55.0.19(expo@55.0.27)(typescript@7.0.2) - '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/prebuild-config': 55.0.19(expo@55.0.27)(typescript@6.0.3) + '@expo/require-utils': 55.0.5(typescript@6.0.3) '@expo/router-server': 55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@expo/schema-utils': 55.0.4 '@expo/spawn-async': 1.7.2 @@ -8264,7 +8147,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.6 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-server: 55.0.11 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -8364,12 +8247,12 @@ snapshots: - supports-color - typescript - '@expo/config@55.0.16(typescript@7.0.2)': + '@expo/config@55.0.16(typescript@6.0.3)': dependencies: '@expo/config-plugins': 55.0.8 '@expo/config-types': 55.0.5 '@expo/json-file': 10.0.14 - '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/require-utils': 55.0.5(typescript@6.0.3) deepmerge: 4.3.1 getenv: 2.0.0 glob: 13.0.6 @@ -8380,12 +8263,12 @@ snapshots: - supports-color - typescript - '@expo/config@55.0.18(typescript@7.0.2)': + '@expo/config@55.0.18(typescript@6.0.3)': dependencies: '@expo/config-plugins': 55.0.10 '@expo/config-types': 55.0.5 '@expo/json-file': 10.2.0 - '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/require-utils': 55.0.5(typescript@6.0.3) deepmerge: 4.3.1 getenv: 2.0.0 glob: 13.0.6 @@ -8412,7 +8295,7 @@ snapshots: '@expo/dom-webview@55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -8440,9 +8323,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/image-utils@0.8.14(typescript@7.0.2)': + '@expo/image-utils@0.8.14(typescript@6.0.3)': dependencies: - '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/require-utils': 55.0.5(typescript@6.0.3) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 getenv: 2.0.0 @@ -8473,9 +8356,9 @@ snapshots: '@babel/code-frame': 7.29.7 json5: 2.2.3 - '@expo/local-build-cache-provider@55.0.14(typescript@7.0.2)': + '@expo/local-build-cache-provider@55.0.14(typescript@6.0.3)': dependencies: - '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config': 55.0.18(typescript@6.0.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color @@ -8485,7 +8368,7 @@ snapshots: dependencies: '@expo/dom-webview': 55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) stacktrace-parser: 0.1.11 @@ -8494,17 +8377,17 @@ snapshots: dependencies: '@expo/dom-webview': 55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) stacktrace-parser: 0.1.11 - '@expo/metro-config@55.0.24(expo@55.0.27)(typescript@7.0.2)': + '@expo/metro-config@55.0.24(expo@55.0.27)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 '@babel/generator': 7.29.7 - '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config': 55.0.18(typescript@6.0.3) '@expo/env': 2.1.2 '@expo/json-file': 10.0.16 '@expo/metro': 55.1.1 @@ -8521,7 +8404,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -8532,7 +8415,7 @@ snapshots: dependencies: '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) pretty-format: 29.7.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -8593,16 +8476,16 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@55.0.17(expo@55.0.27)(typescript@7.0.2)': + '@expo/prebuild-config@55.0.17(expo@55.0.27)(typescript@6.0.3)': dependencies: - '@expo/config': 55.0.16(typescript@7.0.2) + '@expo/config': 55.0.16(typescript@6.0.3) '@expo/config-plugins': 55.0.8 '@expo/config-types': 55.0.5 - '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/image-utils': 0.8.14(typescript@6.0.3) '@expo/json-file': 10.0.14 '@react-native/normalize-colors': 0.83.6 debug: 4.4.3 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.7.4 xml2js: 0.6.0 @@ -8610,16 +8493,16 @@ snapshots: - supports-color - typescript - '@expo/prebuild-config@55.0.19(expo@55.0.27)(typescript@7.0.2)': + '@expo/prebuild-config@55.0.19(expo@55.0.27)(typescript@6.0.3)': dependencies: - '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config': 55.0.18(typescript@6.0.3) '@expo/config-plugins': 55.0.10 '@expo/config-types': 55.0.5 - '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/image-utils': 0.8.14(typescript@6.0.3) '@expo/json-file': 10.2.0 '@react-native/normalize-colors': 0.83.6 debug: 4.4.3 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.8.4 xml2js: 0.6.0 @@ -8637,20 +8520,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/require-utils@55.0.5(typescript@7.0.2)': + '@expo/require-utils@55.0.5(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.0 '@babel/core': 7.29.7 '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) optionalDependencies: - typescript: 7.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@expo/router-server@55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: debug: 4.4.3 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-server: 55.0.11 @@ -8928,7 +8811,7 @@ snapshots: '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -9759,7 +9642,7 @@ snapshots: '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) @@ -9772,7 +9655,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@7.0.2)': + '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 @@ -9780,7 +9663,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 - typescript: 7.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -9847,66 +9730,6 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@typescript/typescript-aix-ppc64@7.0.2': - optional: true - - '@typescript/typescript-darwin-arm64@7.0.2': - optional: true - - '@typescript/typescript-darwin-x64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-x64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm@7.0.2': - optional: true - - '@typescript/typescript-linux-loong64@7.0.2': - optional: true - - '@typescript/typescript-linux-mips64el@7.0.2': - optional: true - - '@typescript/typescript-linux-ppc64@7.0.2': - optional: true - - '@typescript/typescript-linux-riscv64@7.0.2': - optional: true - - '@typescript/typescript-linux-s390x@7.0.2': - optional: true - - '@typescript/typescript-linux-x64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-sunos-x64@7.0.2': - optional: true - - '@typescript/typescript-win32-arm64@7.0.2': - optional: true - - '@typescript/typescript-win32-x64@7.0.2': - optional: true - '@ungap/structured-clone@1.3.1': {} '@vitest/expect@4.1.9': @@ -10267,7 +10090,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -10300,7 +10123,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -10954,7 +10777,7 @@ 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/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) + '@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) @@ -10985,7 +10808,7 @@ snapshots: dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: @@ -11027,7 +10850,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -11203,12 +11026,12 @@ snapshots: expo-application@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) - expo-asset@55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): + expo-asset@55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.8.14(typescript@7.0.2) - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + '@expo/image-utils': 0.8.14(typescript@6.0.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -11219,14 +11042,14 @@ snapshots: expo-build-properties@55.0.13(expo@55.0.27): dependencies: '@expo/schema-utils': 55.0.4 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.7.4 expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: barcode-detector: 3.1.3(@types/emscripten@1.41.5) - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) optionalDependencies: @@ -11236,25 +11059,25 @@ snapshots: expo-clipboard@55.0.13(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) expo-constants@55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: '@expo/env': 2.1.2 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - supports-color expo-crypto@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-dev-client@55.0.35(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-dev-launcher: 55.0.36(expo@55.0.27) expo-dev-menu: 55.0.30(expo@55.0.27) expo-dev-menu-interface: 55.0.2(expo@55.0.27) @@ -11264,67 +11087,67 @@ snapshots: expo-dev-launcher@55.0.36(expo@55.0.27): dependencies: '@expo/schema-utils': 55.0.4 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-dev-menu: 55.0.30(expo@55.0.27) expo-manifests: 55.0.17(expo@55.0.27) expo-dev-menu-interface@55.0.2(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-dev-menu@55.0.30(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-dev-menu-interface: 55.0.2(expo@55.0.27) expo-document-picker@55.0.13(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-file-system@55.0.19(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) expo-file-system@55.0.23(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) expo-font@55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) fontfaceobserver: 2.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) expo-glass-effect@55.0.11(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) expo-haptics@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-image-loader@55.0.1(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-image-manipulator@55.0.17(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-image-loader: 55.0.1(expo@55.0.27) expo-image-picker@55.0.20(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-image-loader: 55.0.1(expo@55.0.27) expo-image@55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) sf-symbols-typescript: 2.2.0 @@ -11335,7 +11158,7 @@ snapshots: expo-keep-awake@55.0.8(expo@55.0.27)(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 expo-linking@55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): @@ -11350,7 +11173,7 @@ snapshots: expo-manifests@55.0.17(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-json-utils: 55.0.2 expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): @@ -11399,9 +11222,9 @@ snapshots: - supports-color - utf-8-validate - expo-modules-autolinking@55.0.24(typescript@7.0.2): + expo-modules-autolinking@55.0.24(typescript@6.0.3): dependencies: - '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/require-utils': 55.0.5(typescript@6.0.3) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 commander: 7.2.0 @@ -11419,15 +11242,15 @@ snapshots: expo-network@55.0.14(expo@55.0.27)(react@19.2.6): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 - expo-notifications@55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): + expo-notifications@55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/image-utils': 0.8.14(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-application: 55.0.14(expo@55.0.27) expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) react: 19.2.6 @@ -11449,7 +11272,7 @@ snapshots: client-only: 0.0.1 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-glass-effect: 55.0.11(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-image: 55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -11487,16 +11310,16 @@ snapshots: expo-secure-store@55.0.13(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-server@55.0.11: {} expo-server@55.0.9: {} - expo-splash-screen@55.0.20(expo@55.0.27)(typescript@7.0.2): + expo-splash-screen@55.0.20(expo@55.0.27)(typescript@6.0.3): dependencies: - '@expo/prebuild-config': 55.0.17(expo@55.0.27)(typescript@7.0.2) - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + '@expo/prebuild-config': 55.0.17(expo@55.0.27)(typescript@6.0.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript @@ -11510,7 +11333,7 @@ snapshots: expo-symbols@55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -11518,29 +11341,29 @@ snapshots: expo-updates-interface@55.1.6(expo@55.0.27): dependencies: - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) - expo@55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): + expo@55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/cli': 55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + '@expo/config': 55.0.18(typescript@6.0.3) '@expo/config-plugins': 55.0.10 '@expo/devtools': 55.0.3(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/fingerprint': 0.16.7 - '@expo/local-build-cache-provider': 55.0.14(typescript@7.0.2) + '@expo/local-build-cache-provider': 55.0.14(typescript@6.0.3) '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro': 55.1.1 - '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2) + '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@6.0.3) '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 55.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2) - expo-asset: 55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-asset: 55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-file-system: 55.0.23(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-keep-awake: 55.0.8(expo@55.0.27)(react@19.2.6) - expo-modules-autolinking: 55.0.24(typescript@7.0.2) + expo-modules-autolinking: 55.0.24(typescript@6.0.3) expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) pretty-format: 29.7.0 react: 19.2.6 @@ -12237,7 +12060,7 @@ snapshots: '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0 babel-jest: 29.7.0(@babel/core@7.29.7) - expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 @@ -14274,28 +14097,7 @@ snapshots: typescript@5.9.3: {} - typescript@7.0.2: - optionalDependencies: - '@typescript/typescript-aix-ppc64': 7.0.2 - '@typescript/typescript-darwin-arm64': 7.0.2 - '@typescript/typescript-darwin-x64': 7.0.2 - '@typescript/typescript-freebsd-arm64': 7.0.2 - '@typescript/typescript-freebsd-x64': 7.0.2 - '@typescript/typescript-linux-arm': 7.0.2 - '@typescript/typescript-linux-arm64': 7.0.2 - '@typescript/typescript-linux-loong64': 7.0.2 - '@typescript/typescript-linux-mips64el': 7.0.2 - '@typescript/typescript-linux-ppc64': 7.0.2 - '@typescript/typescript-linux-riscv64': 7.0.2 - '@typescript/typescript-linux-s390x': 7.0.2 - '@typescript/typescript-linux-x64': 7.0.2 - '@typescript/typescript-netbsd-arm64': 7.0.2 - '@typescript/typescript-netbsd-x64': 7.0.2 - '@typescript/typescript-openbsd-arm64': 7.0.2 - '@typescript/typescript-openbsd-x64': 7.0.2 - '@typescript/typescript-sunos-x64': 7.0.2 - '@typescript/typescript-win32-arm64': 7.0.2 - '@typescript/typescript-win32-x64': 7.0.2 + typescript@6.0.3: {} ua-parser-js@1.0.41: {} diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 0f83bbf5f54..4e24e8c2e06 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -7,7 +7,8 @@ import { useWindowDimensions, ScrollView, Keyboard, - BackHandler + BackHandler, + Modal } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' @@ -264,96 +265,105 @@ 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. return ( - - - - - - - - - {!contentScrollable ? ( - <> - - - - - - {children} - - ) : dragContentToDismiss ? ( - <> - - - - - - - - - - {children} - - - - - - ) : ( - <> - - - - - - - {children} - - - )} - + + + + + - - - + + + + {!contentScrollable ? ( + <> + + + + + + {children} + + ) : dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} + + + + + + ) } diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts new file mode 100644 index 00000000000..792292e6c16 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -0,0 +1,52 @@ +import { + MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, + MOBILE_DICTATION_PCM_SAMPLE_RATE +} from './mobile-dictation-pending-audio-budget' +import { bytesToBase64 } from './mobile-dictation-session-state' +import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio' +import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import type { RpcClient } from '../transport/rpc-client' + +type MobileDictationAudioChunkQueue = { + pendingChunks: Set> + pendingAudioBudget: MobileDictationPendingAudioBudget + shouldReleaseBudget: (dictationId: string) => boolean + failActiveDictation: (dictationId: string, err: unknown) => void +} + +export function enqueueMobileDictationAudioChunk( + client: RpcClient, + dictationId: string, + event: MicrophoneDataEvent, + queue: MobileDictationAudioChunkQueue +): void { + const raw = event.data + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) + const byteLength = bytes.byteLength + if (!queue.pendingAudioBudget.tryReserve(byteLength)) { + queue.failActiveDictation( + dictationId, + new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) + ) + return + } + const sendChunk = client + .sendRequest('speech.dictation.chunk', { + dictationId, + audioBase64: bytesToBase64(bytes), + sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE + }) + .then((response) => { + if (!response.ok) { + throw new Error(response.error.message) + } + }) + .catch((err) => queue.failActiveDictation(dictationId, err)) + .finally(() => { + if (queue.shouldReleaseBudget(dictationId)) { + queue.pendingAudioBudget.release(byteLength) + } + queue.pendingChunks.delete(sendChunk) + }) + queue.pendingChunks.add(sendChunk) +} diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts new file mode 100644 index 00000000000..bdd0b69d963 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +const OK_RESPONSE = { ok: true, result: {} } as const + +type StartHarnessOptions = { + sendRequest?: (method: string) => Promise + acquire?: () => Promise + commitRecordingStart?: () => boolean +} + +function createStartHarness(options: StartHarnessOptions = {}) { + let generation = 1 + let enabled = true + let activeId: string | null = 'dictation-a' + const setIdle = vi.fn() + const release = vi.fn().mockResolvedValue(undefined) + const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true)) + const rollbackRecordingStart = vi.fn() + const sendRequest = vi.fn( + options.sendRequest ?? (async () => OK_RESPONSE) + ) as unknown as RpcClient['sendRequest'] + const client = { sendRequest } as RpcClient + const keepAwakeOwner = { + acquire: vi.fn(options.acquire ?? (async () => undefined)), + release + } as unknown as MobileDictationKeepAwakeOwner + + return { + options: { + client, + dictationId: 'dictation-a', + generation: 1, + getCurrentGeneration: () => generation, + getEnabled: () => enabled, + getActiveId: () => activeId, + clearActiveId: (dictationId: string) => { + if (activeId === dictationId) { + activeId = null + } + }, + setIdle, + keepAwakeOwner, + commitRecordingStart, + rollbackRecordingStart + }, + setNewerStart: () => { + generation = 2 + activeId = 'dictation-b' + }, + setDisabled: () => { + enabled = false + }, + getActiveId: () => activeId, + setIdle, + release, + sendRequest, + commitRecordingStart, + rollbackRecordingStart + } +} + +describe('startMobileDictationDesktopSession', () => { + it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + acquire: async () => setNewerStart() + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('sends desktop cancellation without waiting for a hung keep-awake release', async () => { + vi.useFakeTimers() + try { + let goStale = () => undefined + const harness = createStartHarness({ + acquire: () => { + goStale() + return new Promise(() => undefined) + } + }) + goStale = harness.setNewerStart + // Release queues behind the still-running acquisition, so it never settles. + harness.release.mockReturnValue(new Promise(() => undefined)) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + // Cancellation is dispatched even though release is still pending, so the + // native session tears down without waiting out its own timeout. + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + + void startPromise + } finally { + vi.useRealTimers() + } + }) + + it('returns to idle when disable makes keep-awake acquisition stale', async () => { + let setDisabled = () => undefined + const harness = createStartHarness({ + acquire: async () => setDisabled() + }) + setDisabled = harness.setDisabled + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.getActiveId()).toBeNull() + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('does not hold recording start on a hung keep-awake acquisition', async () => { + vi.useFakeTimers() + try { + const harness = createStartHarness({ + acquire: () => new Promise(() => undefined) + }) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + await expect(startPromise).resolves.toBe(true) + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.release).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('continues dictation when keep-awake acquisition fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const harness = createStartHarness({ + acquire: async () => { + throw new Error('Unable to activate keep awake') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(consoleError).toHaveBeenCalledOnce() + consoleError.mockRestore() + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-a') + expect(harness.release).not.toHaveBeenCalled() + expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + }) + + it('does not surface a desktop-start failure after the start became stale', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setNewerStart() + throw new Error('Desktop start failed') + } + return OK_RESPONSE + } + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('commits recording before returning a current start to the hook', async () => { + const harness = createStartHarness() + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).not.toHaveBeenCalled() + }) + + it('cleans up the keep-awake tag and desktop session when native recording throws', async () => { + const harness = createStartHarness({ + commitRecordingStart: () => { + throw new Error('Audio focus request failed') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Audio focus request failed' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) + + it('rejects and cleans up when the native recorder does not start', async () => { + const harness = createStartHarness({ commitRecordingStart: () => false }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Failed to start microphone recording' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts new file mode 100644 index 00000000000..581b2b99d9f --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -0,0 +1,142 @@ +import { + MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS, + isCurrentMobileDictationStart +} from './mobile-dictation-session-state' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +type StartMobileDictationDesktopSessionOptions = { + client: RpcClient + dictationId: string + generation: number + getCurrentGeneration: () => number + getEnabled: () => boolean + getActiveId: () => string | null + clearActiveId: (dictationId: string) => void + setIdle: () => void + keepAwakeOwner: MobileDictationKeepAwakeOwner + commitRecordingStart: () => boolean + rollbackRecordingStart: () => void +} + +function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean { + return isCurrentMobileDictationStart( + options.getCurrentGeneration(), + options.generation, + options.getEnabled(), + options.getActiveId(), + options.dictationId + ) +} + +function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean { + return options.getCurrentGeneration() === options.generation && options.getEnabled() +} + +function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void { + if (options.getCurrentGeneration() === options.generation) { + options.setIdle() + } +} + +// Cancel a start that went stale mid-startup. The wake-lock release and remote +// cancel are independent, so run them concurrently: awaiting release first can +// queue behind a still-running acquisition and delay cancel for the remainder +// of the native timeout. +async function cancelStaleStart( + options: StartMobileDictationDesktopSessionOptions, + { releaseKeepAwake }: { releaseKeepAwake: boolean } +): Promise { + const { client, dictationId, keepAwakeOwner } = options + options.clearActiveId(dictationId) + setIdleIfGenerationCurrent(options) + const cleanups: Promise[] = [ + client.sendRequest('speech.dictation.cancel', { dictationId }) + ] + if (releaseKeepAwake) { + cleanups.push(keepAwakeOwner.release(dictationId)) + } + await Promise.allSettled(cleanups) +} + +export async function startMobileDictationDesktopSession( + options: StartMobileDictationDesktopSessionOptions +): Promise { + const { client, dictationId, keepAwakeOwner } = options + + try { + const response = await client.sendRequest('speech.dictation.start', { dictationId }) + if (!response.ok) { + throw new Error(response.error.message) + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + options.clearActiveId(dictationId) + await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) + // Awaited cleanup may overlap a newer start; stale work must not reset or + // report over the replacement session. + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: false }) + return false + } + + // Keep-awake is acquired only after the desktop session exists, so stale + // mobile starts can be canceled without holding a screen-lock tag. It is + // best-effort: Android throws with no current Activity, and a screen-lock + // nicety must not abort an otherwise viable dictation — nor delay recording + // past a small budget when native calls hang. A late acquisition finishes in + // the background; the serialized keep-awake queue orders any later release + // after it. + await new Promise((resolve) => { + const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + keepAwakeOwner + .acquire(dictationId) + .catch((err: unknown) => console.error('Keep-awake activation failed', err)) + .finally(() => { + clearTimeout(budgetTimer) + resolve() + }) + }) + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: true }) + return false + } + + try { + // Commit in the same continuation as the final stale check; returning first + // would let a queued cancel resurrect microphone recording after cleanup. + if (!options.commitRecordingStart()) { + throw new Error('Failed to start microphone recording') + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + // Native recording can partially start before throwing, so stop audio before + // releasing the wake tag and remote session. + try { + options.rollbackRecordingStart() + } catch { + // Continue releasing independently owned resources after native audio failure. + } + options.clearActiveId(dictationId) + await Promise.allSettled([ + keepAwakeOwner.release(dictationId), + client.sendRequest('speech.dictation.cancel', { dictationId }) + ]) + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + return true +} diff --git a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts new file mode 100644 index 00000000000..d5962d9d11e --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { AppState, Platform } from 'react-native' +import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' +import type { RefObject } from 'react' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' + +// A transient Activity gap can fail a foreground refresh; retry briefly while +// the same dictation is live instead of waiting for the next foreground. +const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000] + +let globalStaleTagDrainInstalled = false + +// Failed final deactivations must be retried even after every session screen +// unmounts, or a stale native tag keeps the screen awake until app restart. +// Installed once for the app's lifetime; the drain spares still-wanted tags +// and fast-paths to a no-op when nothing is pending. +function installGlobalStaleTagForegroundDrain(): void { + if (globalStaleTagDrainInstalled) { + return + } + globalStaleTagDrainInstalled = true + AppState.addEventListener('change', (state) => { + if (state === 'active') { + void drainMobileDictationKeepAwakeCleanup().catch(() => undefined) + } + }) +} + +export function useMobileDictationForegroundKeepAwake( + keepAwakeOwner: MobileDictationKeepAwakeOwner, + activeIdRef: RefObject +): void { + useEffect(() => { + installGlobalStaleTagForegroundDrain() + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity + // recreation silently drops it mid-dictation; refresh on return to + // active. iOS re-applies natively on foreground. + if (Platform.OS !== 'android') { + return + } + // A retry from an earlier foreground event can outlive a newer reacquire and + // deactivate the recovered tag; a run token invalidated on each AppState + // change and on unmount drops superseded retry chains. + let reacquireRun = 0 + const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => { + void keepAwakeOwner.reacquire(dictationId).catch(() => { + const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] + if (delay === undefined) { + return + } + setTimeout(() => { + if (reacquireRun === run && activeIdRef.current === dictationId) { + reacquireWithRetry(dictationId, attempt + 1, run) + } + }, delay) + }) + } + const sub = AppState.addEventListener('change', (state) => { + const run = ++reacquireRun + const dictationId = activeIdRef.current + if (state === 'active' && dictationId) { + reacquireWithRetry(dictationId, 0, run) + } + }) + return () => { + reacquireRun += 1 + sub.remove() + } + }, [keepAwakeOwner, activeIdRef]) +} diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts new file mode 100644 index 00000000000..362f3a0cf68 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const keepAwake = vi.hoisted(() => ({ + activate: vi.fn<(tag: string) => Promise>(), + deactivate: vi.fn<(tag: string) => Promise>() +})) + +vi.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: keepAwake.activate, + deactivateKeepAwake: keepAwake.deactivate +})) + +import { + MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, + MobileDictationKeepAwakeOwner, + drainMobileDictationKeepAwakeCleanup +} from './mobile-dictation-keep-awake' + +function deferred(): { + promise: Promise + resolve: () => void + reject: (error: Error) => void +} { + let resolvePromise: (() => void) | undefined + let rejectPromise: ((error: Error) => void) | undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + resolve: () => resolvePromise?.(), + reject: (error) => rejectPromise?.(error) + } +} + +describe('MobileDictationKeepAwakeOwner', () => { + beforeEach(() => { + keepAwake.activate.mockReset().mockResolvedValue(undefined) + keepAwake.deactivate.mockReset().mockResolvedValue(undefined) + }) + + it('retries a failed native deactivation after the hook owner is replaced', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + + await firstOwner.acquire('first') + const firstTag = keepAwake.activate.mock.calls[0]?.[0] + expect(firstTag).toContain(':first') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + const replacementOwner = new MobileDictationKeepAwakeOwner() + await replacementOwner.acquire('second') + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + expect(secondTag).toContain(':second') + expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await replacementOwner.release('second') + }) + + it('serializes cancel and restart without letting a stale release deactivate the restart', async () => { + const firstActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => firstActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + + const acquireFirst = owner.acquire('first') + const releaseFirst = owner.release('first') + const acquireSecond = owner.acquire('second') + firstActivation.resolve() + await Promise.all([acquireFirst, releaseFirst, acquireSecond]) + + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + await owner.release('first') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('second') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag) + }) + + it('waits for an in-flight failed release before a replacement owner activates', async () => { + const deactivation = deferred() + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) + + const releaseFirst = firstOwner.release('first') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(keepAwake.deactivate).toHaveBeenCalledOnce() + + const replacementOwner = new MobileDictationKeepAwakeOwner() + const acquireReplacement = replacementOwner.acquire('replacement') + expect(keepAwake.activate).toHaveBeenCalledOnce() + + deactivation.reject(new Error('Activity unavailable')) + await expect(releaseFirst).rejects.toThrow('Activity unavailable') + await acquireReplacement + + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + await replacementOwner.release('replacement') + }) + + it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + + // Both the release deactivate and its trailing drain retry fail. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + const replacementOwner = new MobileDictationKeepAwakeOwner() + await expect(replacementOwner.acquire('second')).resolves.toBeUndefined() + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + // The still-pending first tag drains once a deactivation finally succeeds. + await replacementOwner.release('second') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength( + 4 + ) + }) + + it('times out a never-settling native call instead of wedging the queue', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const hungOwner = new MobileDictationKeepAwakeOwner() + const hungAcquire = hungOwner.acquire('hung') + // Drain microtasks to quiescence so the timeout timer is registered. + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out') + + // The queue must advance, and another owner's drain must spare the + // still-wanted maybe-late activation. + const nextOwner = new MobileDictationKeepAwakeOwner() + await nextOwner.acquire('next') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next') + + // Once its own dictation ends, the orphan gets cleaned. + await hungOwner.release('hung') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength( + 1 + ) + await nextOwner.release('next') + } finally { + vi.useRealTimers() + } + }) + + it('adopts a timed-out activation that lands late while the dictation is live', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('late') + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted, not deactivated: protection stays on for the live dictation. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('late') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength( + 1 + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not let another owner drain a still-wanted timed-out activation', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const ownerA = new MobileDictationKeepAwakeOwner() + const acquireA = ownerA.acquire('wanted') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquireA).rejects.toThrow('Keep-awake native call timed out') + + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted for owner A; released like a normal activation afterwards. + await ownerA.release('wanted') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted')) + ).toHaveLength(1) + await ownerB.release('other') + } finally { + vi.useRealTimers() + } + }) + + it('deactivates a late-landing activation once its dictation has ended', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('ended') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.release('ended') + lateActivation.resolve() + await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)) + expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retries a timed-out final deactivation via the foreground drain', async () => { + vi.useFakeTimers() + try { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('final') + const tag = keepAwake.activate.mock.calls[0]?.[0] + // The release deactivate times out and its trailing drain retry fails. + keepAwake.deactivate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockRejectedValueOnce(new Error('Activity unavailable')) + const release = owner.release('final') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(release).rejects.toThrow('Keep-awake native call timed out') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + + await drainMobileDictationKeepAwakeCleanup() + expect(keepAwake.deactivate).toHaveBeenCalledTimes(3) + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + } finally { + vi.useRealTimers() + } + }) + + it('drains orphaned tags on release, not only on the next acquire', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('orphan') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + // The dictation ends without another acquire; release must still clean. + await owner.release('orphan') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan')) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it('recovers on foreground reacquire after a failed initial acquisition', async () => { + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + const owner = new MobileDictationKeepAwakeOwner() + await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.reacquire('current') + const tag = keepAwake.activate.mock.calls[1]?.[0] + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(tag).toContain(':current') + // A definite rejection activated nothing, so recovery must not deactivate. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('recovers keep-awake on a later reacquire after a failed refresh', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + // Refresh loses the activation: deactivate succeeds, activate rejects. + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('current') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag) + // Only the pre-refresh deactivate ran; recovery must not deactivate again. + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('records new-dictation intent even when previous-tag cleanup fails', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('first') + + // Release and its trailing drain both fail; the owner keeps stale intent. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(owner.release('first')).rejects.toThrow('Activity unavailable') + + // The next acquire's drain and previous-tag cleanup fail too, and the new + // activation itself fails — intent must still be recorded for the heal. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('second') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second') + + await owner.release('second') + }) + + it('keeps tracking a newer timed-out activation when an older one settles late', async () => { + vi.useFakeTimers() + try { + const first = deferred() + keepAwake.activate.mockImplementationOnce(() => first.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + const second = deferred() + keepAwake.activate.mockImplementationOnce(() => second.promise) + const reacquire = owner.reacquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(reacquire).rejects.toThrow('Keep-awake native call timed out') + + // The older activation settles late with a rejection; the newer + // activation's tracking must survive it. + first.reject(new Error('Activity unavailable')) + await vi.advanceTimersByTimeAsync(0) + + await owner.release('stacked') + // Exactly two: the reacquire's deactivate-first pass, plus the release + // drain cleaning the newer entry the stale settle must not have deleted. + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked')) + ).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('reacquires a maybe-active timed-out activation by deactivating first', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('maybe') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.reacquire('maybe') + // The ambiguous activation may be natively live; it must be deactivated + // before the fresh activate so Android re-applies the window flag. + const tag = keepAwake.activate.mock.calls[0]?.[0] + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.release('maybe') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => { + const ownerA = new MobileDictationKeepAwakeOwner() + await ownerA.acquire('live') + const liveTag = keepAwake.activate.mock.calls[0]?.[0] + + // Foreground refresh: the deactivate leg fails; the tag stays native-on + // and the failure surfaces so the caller's bounded retry can kick in. + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // Another owner's drain must spare the still-wanted live tag. + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // The next foreground retries the full deactivate-then-activate refresh. + await ownerA.reacquire('live') + expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2) + + await ownerA.release('live') + await ownerB.release('other') + }) + + it('reacquires by deactivating before activating so Android re-applies the window flag', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + await owner.reacquire('current') + + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.reacquire('other') + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + await owner.release('current') + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts new file mode 100644 index 00000000000..f94e3fb785a --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -0,0 +1,236 @@ +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' + +const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' + +// Native keep-awake promises can be lost during Activity teardown; a bounded +// wait keeps the serialized queue below from wedging dictation until restart. +export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 + +let nextOwnerId = 0 +let keepAwakeOperation: Promise = Promise.resolve() +const activeTags = new Set() +const pendingCleanupTags = new Set() +// Timed-out activations that may still land natively, keyed to a predicate +// saying whether their dictation still wants the tag. +const pendingActivations = new Map boolean>() + +function createOwnerId(): string { + nextOwnerId += 1 + return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}` +} + +function enqueueKeepAwakeOperation(action: () => Promise): Promise { + const operation = keepAwakeOperation.then(action) + keepAwakeOperation = operation.catch(() => undefined) + return operation +} + +const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError' + +function isNativeCallTimeout(err: unknown): boolean { + return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME +} + +function withNativeCallTimeout(nativeCall: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const timeoutError = new Error('Keep-awake native call timed out') + timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME + reject(timeoutError) + }, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + nativeCall.then( + () => { + clearTimeout(timer) + resolve() + }, + (err: unknown) => { + clearTimeout(timer) + reject(err instanceof Error ? err : new Error(String(err))) + } + ) + }) +} + +async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise { + const nativeActivation = activateKeepAwakeAsync(tag) + try { + await withNativeCallTimeout(nativeActivation) + } catch (err) { + // Only a timeout is ambiguous: the activation can still take effect late. + // A definite native rejection activated nothing and needs no cleanup. + if (isNativeCallTimeout(err)) { + // Tracked apart from deactivation retries so drains (including another + // owner's) never turn off an activation its dictation still wants. + pendingActivations.set(tag, isStillWanted) + nativeActivation.then( + () => + void enqueueKeepAwakeOperation(async () => { + // Delete only this activation's own entry — a newer timed-out + // activation of the same tag may have replaced it. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + if (activeTags.has(tag)) { + return + } + if (isStillWanted()) { + // The activation landed late but its dictation is still live; + // adopt it rather than turning off screen-lock protection. + activeTags.add(tag) + return + } + // No owner wants it anymore — the screen must not stay awake. + await deactivateTrackedTag(tag).catch(() => undefined) + }), + () => { + // A late definite rejection means nothing activated after all, but + // spare a newer activation's entry keyed to the same tag. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + } + ) + } + throw err + } + activeTags.add(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function deactivateTrackedTag(tag: string): Promise { + try { + await withNativeCallTimeout(deactivateKeepAwake(tag)) + } catch (err) { + // A replacement hook must be able to retry cleanup after Android replaces + // an Activity and the owner that acquired this tag has unmounted. + pendingCleanupTags.add(tag) + throw err + } + activeTags.delete(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function cleanupPendingTags(): Promise { + const staleTags = new Set(pendingCleanupTags) + for (const [tag, isStillWanted] of pendingActivations) { + // A still-wanted timed-out activation is not an orphan: deactivating it + // would turn off screen-lock protection for a live dictation. + if (!isStillWanted()) { + pendingActivations.delete(tag) + staleTags.add(tag) + } + } + if (staleTags.size === 0) { + return + } + // Retry concurrently so N stale tags cost one timeout window, not N, and + // swallow failures: a stale tag that still cannot be deactivated must not + // fail the fresh acquire that triggered this retry; it stays queued. + await Promise.allSettled( + Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined)) + ) +} + +export class MobileDictationKeepAwakeOwner { + private readonly ownerId = createOwnerId() + private acquiredTag: string | null = null + + acquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag && !activeTags.has(this.acquiredTag)) { + this.acquiredTag = null + } + if (this.acquiredTag === tag) { + return + } + if (this.acquiredTag) { + const previousTag = this.acquiredTag + this.acquiredTag = null + // Best-effort: a failed previous-tag cleanup is queued for retry and + // must not block recording intent for the new dictation below. + await deactivateTrackedTag(previousTag).catch(() => undefined) + } + // Record ownership before the native call: acquiredTag is intent while + // activeTags is native state, so a failed initial activation can still + // be healed by a later foreground reacquire. + this.acquiredTag = tag + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated + // Activity silently loses it while native tags persist — and native activate + // skips re-applying the flag while any tag remains, so deactivate first. + reacquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag !== tag) { + return + } + // A timed-out activation may be natively active too, and Android only + // re-applies the window flag from an empty tag set — deactivate both. + if (activeTags.has(tag) || pendingActivations.has(tag)) { + try { + await deactivateTrackedTag(tag) + } catch (err) { + // A still-live tag must not sit in the orphan pool where another + // owner's drain would turn it off without reactivating; keep it in + // the wanted pool and surface the failure so the caller can retry + // before the next foreground event. + pendingCleanupTags.delete(tag) + pendingActivations.set(tag, () => this.acquiredTag === tag) + throw err + } + } + // Also recovers an activation lost to an earlier native failure, so a + // later foreground event can restore keep-awake instead of no-oping. + // Known gap: if another expo-keep-awake owner exists (e.g. dev-build + // dev tools), the native module never empties its tag set, so the + // deactivate/activate cycle cannot re-apply the Android window flag. + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + release(dictationId?: string): Promise { + const targetTag = dictationId ? this.createTag(dictationId) : null + return enqueueKeepAwakeOperation(async () => { + try { + const tag = this.acquiredTag + if (!tag || (targetTag && tag !== targetTag)) { + return + } + if (!activeTags.has(tag)) { + this.acquiredTag = null + return + } + await deactivateTrackedTag(tag) + this.acquiredTag = null + } finally { + // Drain after the owner-local unset so this owner's own timed-out + // activation is no longer wanted and gets cleaned here — an acquire + // may never happen again this session. Still-wanted tags of other + // live dictations are spared by the drain itself. + await cleanupPendingTags() + } + }) + } + + private createTag(dictationId: string): string { + return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}` + } +} + +export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner { + return new MobileDictationKeepAwakeOwner() +} + +// Foreground is the retry point for wake tags whose final deactivation timed +// out after a dictation ended — otherwise nothing runs until the next one. +export function drainMobileDictationKeepAwakeCleanup(): Promise { + return enqueueKeepAwakeOperation(cleanupPendingTags) +} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts new file mode 100644 index 00000000000..bfc2dbd8d29 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -0,0 +1,65 @@ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because +// Metro cannot resolve Node builtins in a React Native bundle. +import { Buffer } from 'buffer' + +import type { RpcClient } from '../transport/rpc-client' + +export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' + +export type UseMobileDictationOptions = { + client: RpcClient | null + enabled: boolean + onTranscript: (text: string) => void + onError?: (error: Error) => void +} + +export type UseMobileDictationResult = { + status: DictationStatus + isStarting: boolean + isRecording: boolean + isProcessing: boolean + error: string | null + start: () => Promise + stop: () => Promise + cancel: () => Promise +} + +export const DICTATION_FINISH_TIMEOUT_MS = 75_000 + +// Recording start waits at most this long for the best-effort wake tag; a +// slow or hung native keep-awake module must not hold the mic in 'starting'. +export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500 + +export function bytesToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +export function createMobileDictationId(): string { + return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function isCurrentMobileDictationStart( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + dictationId: string +): boolean { + return currentGeneration === generation && enabled && activeId === dictationId +} + +export function isCurrentMobileDictationFinish( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + finishingId: string | null, + dictationId: string +): boolean { + return ( + currentGeneration === generation && + enabled && + activeId === dictationId && + finishingId === dictationId + ) +} diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 6319e2d9b5a..0649e7ee54b 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') +const audioChunkSource = readFileSync( + new URL('./mobile-dictation-audio-chunk.ts', import.meta.url), + 'utf8' +) +const keepAwakeSource = readFileSync( + new URL('./mobile-dictation-keep-awake.ts', import.meta.url), + 'utf8' +) +const desktopStartSource = readFileSync( + new URL('./mobile-dictation-desktop-start.ts', import.meta.url), + 'utf8' +) +const sessionStateSource = readFileSync( + new URL('./mobile-dictation-session-state.ts', import.meta.url), + 'utf8' +) +const foregroundKeepAwakeSource = readFileSync( + new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url), + 'utf8' +) + +function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { + const start = sourceText.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = sourceText.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return sourceText.slice(start, end) +} function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) + return sliceSource(source, startPattern, endPattern) +} + +function sliceDesktopStartBetween(startPattern: string, endPattern: string): string { + return sliceSource(desktopStartSource, startPattern, endPattern) } describe('useMobileDictation source invariants', () => { @@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => { }) it('reserves pending audio bytes before encoding microphone chunks', () => { - const microphoneEffect = sliceBetween( - "addExpoTwoWayAudioEventListener('onMicrophoneData'", - 'return () => sub.remove()' + const microphoneEffect = sliceSource( + audioChunkSource, + 'export function enqueueMobileDictationAudioChunk', + ' queue.pendingChunks.add(sendChunk)' ) const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)') @@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => { expect(encodeIndex).toBeGreaterThanOrEqual(0) expect(reserveIndex).toBeLessThan(encodeIndex) expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)') + expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)') + expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event') + }) + + it('reuses audio chunk queue wiring across microphone events', () => { + const queueIndex = source.indexOf('const audioChunkQueue =') + const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'") + + expect(queueIndex).toBeGreaterThanOrEqual(0) + expect(queueIndex).toBeLessThan(listenerIndex) + expect(source).toContain( + 'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)' + ) }) it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { @@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => { expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) }) + + it('keeps mobile dictation keep-awake ownership beside the hook', () => { + expect(source).toMatch( + /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ + ) + expect(source).toContain( + 'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])' + ) + expect(keepAwakeSource).toContain('activateKeepAwakeAsync') + expect(keepAwakeSource).toContain('deactivateKeepAwake') + expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/) + }) + + it('acquires keep-awake only after desktop start and stale-start guards', () => { + const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =') + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const desktopStartIndex = startBody.indexOf( + "client.sendRequest('speech.dictation.start', { dictationId })" + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') + const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)') + + expect(desktopStartIndex).toBeGreaterThanOrEqual(0) + expect(acquireIndex).toBeGreaterThan(desktopStartIndex) + expect(desktopSessionIndex).toBeGreaterThanOrEqual(0) + expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex) + expect(hookStartBody).toContain('commitRecordingStart: () => {') + expect(startBody).toContain('options.commitRecordingStart()') + + const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex) + expect(beforeAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(sessionStateSource).toContain( + 'currentGeneration === generation && enabled && activeId === dictationId' + ) + }) + + it('re-checks stale-start guards after awaited keep-awake acquisition', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const returnStartedIndex = startBody.indexOf('return true') + const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) + + expect(afterAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })') + + // The stale-start cleanup must release the wake tag and cancel the desktop + // session (concurrently, so a hung acquisition can't delay the cancel). + const cancelStaleStartBody = sliceDesktopStartBetween( + 'async function cancelStaleStart', + 'export async function startMobileDictationDesktopSession' + ) + expect(cancelStaleStartBody).toContain( + "client.sendRequest('speech.dictation.cancel', { dictationId })" + ) + expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))') + expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)') + }) + + it('treats keep-awake acquisition as best-effort for the desktop session', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + expect(acquireIndex).toBeGreaterThanOrEqual(0) + const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) + expect(staleCheckIndex).toBeGreaterThan(acquireIndex) + + // An acquisition failure must not cancel the dictation or surface an error. + const acquireChain = startBody.slice(acquireIndex, staleCheckIndex) + expect(acquireChain).toContain('.catch(') + expect(acquireChain).not.toContain('throw') + }) + + it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan( + closeAudio.indexOf('void keepAwakeOwner.release') + ) + expect(closeAudio).toContain('.catch(() => undefined)') + + const cleanupSlices = [ + sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), + sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'), + sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') + ] + + for (const cleanupSlice of cleanupSlices) { + expect(cleanupSlice).toContain('closeDictationAudio(dictationId)') + } + + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan( + stopBody.indexOf('await Promise.allSettled') + ) + // The wake tag must be held through chunk drain and the finish RPC so a + // screen lock cannot suspend the app before the transcript arrives. + expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + expect(stopBody.indexOf('} finally {')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + }) + + it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => { + expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)') + expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'") + expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)') + // A transiently failing refresh retries while the dictation is live. + expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]') + expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId') + // Stale-tag retries survive hook unmount via a module-level listener. + expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()') + expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()') + + // Native activate skips re-applying the window flag while any tag remains, + // so reacquire must deactivate before activating. + const reacquireBody = sliceSource( + keepAwakeSource, + 'reacquire(dictationId: string)', + 'release(dictationId?: string)' + ) + expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan( + reacquireBody.indexOf('await activateTrackedTag(tag,') + ) + }) + + it('keeps cleanup going when native recording shutdown throws', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + const toggleIndex = closeAudio.indexOf('toggleRecording(false)') + const catchIndex = closeAudio.indexOf('} catch', toggleIndex) + const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release') + expect(toggleIndex).toBeGreaterThanOrEqual(0) + expect(catchIndex).toBeGreaterThan(toggleIndex) + expect(catchIndex).toBeLessThan(releaseIndex) + + // stop()'s recording shutdown sits inside the try so a native throw still + // runs the finally release and error cleanup. + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0) + expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)')) + }) + + it('routes disabled state and audio interruptions through cancel cleanup', () => { + const interruptionEffect = sliceBetween( + "addExpoTwoWayAudioEventListener('onAudioInterruption'", + 'return () => sub.remove()' + ) + const disabledEffect = sliceBetween( + 'useEffect(() => {\n if (!enabled) {', + ' }, [cancel, enabled])' + ) + + expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'") + expect(interruptionEffect).toContain('void cancel()') + expect(disabledEffect).toContain('void cancel()') + }) + + it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => { + expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()') + expect(keepAwakeSource).toContain( + '`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`' + ) + expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise = Promise.resolve()') + expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set()') + expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3) + expect(keepAwakeSource).toContain( + 'const targetTag = dictationId ? this.createTag(dictationId) : null' + ) + expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))') + expect(keepAwakeSource).toContain('await cleanupPendingTags()') + }) }) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index a1028cf9205..648048dc451 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,7 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { addExpoTwoWayAudioEventListener, initialize, @@ -9,45 +6,27 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import type { RpcClient } from '../transport/rpc-client' +import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk' +import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake' import { - MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, - MOBILE_DICTATION_PCM_SAMPLE_RATE, - MobileDictationPendingAudioBudget -} from './mobile-dictation-pending-audio-budget' + DICTATION_FINISH_TIMEOUT_MS, + createMobileDictationId, + isCurrentMobileDictationFinish +} from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { + DictationStatus, + UseMobileDictationOptions, + UseMobileDictationResult +} from './mobile-dictation-session-state' -type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' - -type UseMobileDictationOptions = { - client: RpcClient | null - enabled: boolean - onTranscript: (text: string) => void - onError?: (error: Error) => void -} - -export type UseMobileDictationResult = { - status: DictationStatus - isStarting: boolean - isRecording: boolean - isProcessing: boolean - error: string | null - start: () => Promise - stop: () => Promise - cancel: () => Promise -} - -const DICTATION_FINISH_TIMEOUT_MS = 75_000 - -function bytesToBase64(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('base64') -} - -function createDictationId(): string { - return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` -} +export type { UseMobileDictationResult } from './mobile-dictation-session-state' export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options + const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, []) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) @@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil onErrorRef.current?.(normalized) }, []) + const closeDictationAudio = useCallback( + (dictationId?: string | null) => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + try { + toggleRecording(false) + } catch (err) { + // Cleanup must keep going when native recording shutdown throws, or + // the wake tag and dictation state would leak. + console.error('Failed to stop microphone recording', err) + } + void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) + }, + [keepAwakeOwner] + ) + const failActiveDictation = useCallback( (dictationId: string, err: unknown) => { const client = clientRef.current @@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } activeIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } reportError(err) }, - [reportError] + [closeDictationAudio, reportError] ) useEffect(() => { + // Microphone events are a hot path; reuse this wiring instead of allocating + // a queue object and release predicate for every audio chunk. + const audioChunkQueue = { + pendingChunks: pendingChunksRef.current, + pendingAudioBudget: pendingAudioBudgetRef.current, + shouldReleaseBudget: (id: string) => + activeIdRef.current === id || finishingIdRef.current === id, + failActiveDictation + } const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const client = clientRef.current const dictationId = activeIdRef.current if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) { return } - const raw = event.data - const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) - const byteLength = bytes.byteLength - if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) { - failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)) - return - } - const sendChunk = client - .sendRequest('speech.dictation.chunk', { - dictationId, - audioBase64: bytesToBase64(bytes), - sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE - }) - .then((response) => { - if (!response.ok) { - throw new Error(response.error.message) - } - }) - .catch((err) => failActiveDictation(dictationId, err)) - .finally(() => { - if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) { - pendingAudioBudgetRef.current.release(byteLength) - } - pendingChunksRef.current.delete(sendChunk) - }) - pendingChunksRef.current.add(sendChunk) + enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue) }) return () => sub.remove() }, [failActiveDictation, reportError]) @@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error('Failed to initialize microphone') } - const dictationId = createDictationId() + const dictationId = createMobileDictationId() activeIdRef.current = dictationId - try { - const response = await client.sendRequest('speech.dictation.start', { dictationId }) - if (!response.ok) { - throw new Error(response.error.message) - } - } catch (err) { - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - setStatus('idle') - throw err - } - if ( - generationRef.current !== generation || - !enabledRef.current || - activeIdRef.current !== dictationId - ) { - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - setStatus('idle') - return - } - acceptingChunksRef.current = true - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(true) - setStatus('recording') - }, []) + await startMobileDictationDesktopSession({ + client, + dictationId, + generation, + getCurrentGeneration: () => generationRef.current, + getEnabled: () => enabledRef.current, + getActiveId: () => activeIdRef.current, + clearActiveId: (id) => { + if (activeIdRef.current === id) { + activeIdRef.current = null + } + }, + setIdle: () => setStatus('idle'), + keepAwakeOwner, + commitRecordingStart: () => { + acceptingChunksRef.current = true + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + if (!toggleRecording(true)) { + return false + } + setStatus('recording') + return true + }, + rollbackRecordingStart: () => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + toggleRecording(false) + } + }) + }, [keepAwakeOwner]) const stop = useCallback(async () => { const client = clientRef.current @@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = dictationId setStatus('processing') acceptingChunksRef.current = false - toggleRecording(false) try { + // Inside the try so a throwing native shutdown still runs the finally + // release and error cleanup. + toggleRecording(false) await Promise.allSettled(Array.from(pendingChunksRef.current)) if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error(response.error.message) } if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -257,11 +245,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } catch (err) { failActiveDictation(dictationId, err) } finally { + // Hold the wake tag through chunk drain and the finish RPC: a screen + // lock mid-processing suspends the app and loses the transcript. + void keepAwakeOwner.release(dictationId).catch(() => undefined) if (finishingIdRef.current === dictationId) { finishingIdRef.current = null } } - }, [failActiveDictation]) + }, [failActiveDictation, keepAwakeOwner]) const cancel = useCallback(async () => { const client = clientRef.current @@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } setStatus('idle') setError(null) - }, []) + }, [closeDictationAudio]) + + useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef) useEffect(() => { const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => { @@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) void tearDown() if (clientRef.current && dictationId) { void clientRef.current @@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil .catch(() => undefined) } } - }, []) + }, [closeDictationAudio]) return { status, diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index beb8118dabf..06174cd0378 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -23,6 +23,7 @@ type TerminalPaneViewProps = { onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalQueryReply: (handle: string, bytes: string) => void onTerminalTap: (handle: string) => void onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void onOpenUrl: (handle: string, url: string) => void @@ -44,6 +45,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -80,6 +82,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)} onTerminalTap={() => onTerminalTap(handle)} onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)} onOpenUrl={(url) => onOpenUrl(handle, url)} diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index c97849298fb..be7de3ed683 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -76,7 +76,9 @@ describe('buildMobileAiVaultResumeCommand', () => { ).toBe("cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/sess.jsonl'") }) - it('uses cmd wrapping when the host Windows terminal is configured as cmd', () => { + it('uses direct cmd syntax when the host Windows terminal is already cmd', () => { + // Why: resume text is typed into the live host tab. Nested `cmd /d /s /c` + // wrappers break interactive cmd quoting (see buildAiVaultResumeShellCommand). const command = buildMobileAiVaultResumeCommand({ session: session({ agent: 'codex', @@ -87,10 +89,9 @@ describe('buildMobileAiVaultResumeCommand', () => { hostPlatform: 'win32', hostTerminalWindowsShell: 'cmd.exe' }) - expect(command).toContain('cmd /d /s /c') - expect(command).toContain('cd /d ""C:\\repo app""') - expect(command).toContain('set ""CODEX_HOME=C:\\Users\\Ada\\.codex""') - expect(command).toContain('codex resume ""codex-1""') + expect(command).toBe( + 'cd /d "C:\\repo app" && set "CODEX_HOME=C:\\Users\\Ada\\.codex" && codex resume "codex-1"' + ) }) it('uses POSIX command construction and converts Codex home for WSL targets', () => { diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts new file mode 100644 index 00000000000..97927c6b7d9 --- /dev/null +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useLiveWorktreeName } from './use-live-worktree-name' + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(effect, [effect]) + } + } +}) + +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() +} + +describe('useLiveWorktreeName request volume', () => { + let renderer: ReactTestRenderer | null = null + let eventListener: ((payload: unknown) => void) | null = null + const unsubscribeStream = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + eventListener = listener + return unsubscribeStream + } + ) + const client = { sendRequest, subscribe } as unknown as RpcClient + + async function mountHarness(): Promise { + function Harness(): null { + useLiveWorktreeName({ + client, + connState: 'connected', + routeName: 'Route name', + worktreeId: 'repo-1::/worktree' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + } + + async function emitEvent(payload: unknown): Promise { + await act(async () => { + eventListener?.(payload) + await Promise.resolve() + await Promise.resolve() + }) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + eventListener = null + sendRequest.mockClear().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + subscribe.mockClear() + unsubscribeStream.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('cuts thirty-second idle request volume from eleven calls to one', async () => { + await mountHarness() + expect(subscribe).toHaveBeenCalledWith( + 'runtime.clientEvents.subscribe', + null, + expect.any(Function) + ) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + // The retired 3s interval made one initial request plus ten idle ticks here. + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-2' }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-1' }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps polling when an older runtime rejects the event stream', async () => { + await mountHarness() + await emitEvent({ type: 'error', message: 'Unknown method' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(9_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(4) + }) + + it('retries a failed initial read even after the event stream is ready', async () => { + sendRequest.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('refreshes once when a reconnected stream becomes ready again', async () => { + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-2' }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('unsubscribes when the screen loses focus', async () => { + await mountHarness() + act(() => renderer?.unmount()) + renderer = null + + expect(unsubscribeStream).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index 665fcea3a1e..bd21ec8f156 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useState } from 'react' import { useFocusEffect } from 'expo-router' +import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' +import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 + type Params = { client: RpcClient | null connState: ConnectionState @@ -24,12 +28,27 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } return } let stale = false - const refreshWorktreeName = async () => { + let eventStreamReady = false + let hasSuccessfulRefresh = false + let fallbackInterval: ReturnType | null = null + let refreshGeneration = 0 + const repoId = getRepoIdFromWorktreeId(worktreeId) + + const stopFallbackPoll = (): void => { + if (fallbackInterval !== null) { + clearInterval(fallbackInterval) + fallbackInterval = null + } + } + const refreshWorktreeName = async (): Promise => { + // Why: an event-driven refresh can overtake a slow fallback request; + // only the newest read may publish or stop the retry poll. + const generation = ++refreshGeneration try { const response = await client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` }) - if (stale || !response.ok) { + if (stale || generation !== refreshGeneration || !response.ok) { return } const result = (response as RpcSuccess).result as { @@ -41,17 +60,73 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } if (liveName) { setWorktreeName((current) => (current === liveName ? current : liveName)) } + hasSuccessfulRefresh = true + if (eventStreamReady) { + stopFallbackPoll() + } } catch { // Non-fatal: the route param remains a usable label until the next refresh. } } + + const startFallbackPoll = (): void => { + if (stale || fallbackInterval !== null) { + return + } + fallbackInterval = setInterval( + () => void refreshWorktreeName(), + WORKTREE_NAME_FALLBACK_POLL_MS + ) + } + const invalidateAndRefresh = (): void => { + hasSuccessfulRefresh = false + startFallbackPoll() + void refreshWorktreeName() + } + + startFallbackPoll() + const unsubscribe = client.subscribe( + 'runtime.clientEvents.subscribe', + null, + (payload: unknown) => { + if (stale || !payload || typeof payload !== 'object') { + return + } + const event = payload as RuntimeClientEventStreamMessage | { type: 'error' } + if (event.type === 'ready') { + const replayedAfterReconnect = eventStreamReady + eventStreamReady = true + if (hasSuccessfulRefresh) { + stopFallbackPoll() + } + if (replayedAfterReconnect) { + // Why: client events are not queued while disconnected, so replay + // readiness must re-read the title once to close that event gap. + invalidateAndRefresh() + } + return + } + if (event.type === 'end' || event.type === 'error') { + eventStreamReady = false + startFallbackPoll() + return + } + if ( + event.type === 'reposChanged' || + (event.type === 'worktreesChanged' && event.repoId === repoId) + ) { + invalidateAndRefresh() + } + } + ) // Why: route params are only an entry hint. The desktop/runtime owns - // displayName, including task-generated names that may settle after open. + // displayName. Modern runtimes push invalidations; the poll remains only + // until that stream proves available, preserving older-runtime behavior. void refreshWorktreeName() - const interval = setInterval(() => void refreshWorktreeName(), 3000) return () => { stale = true - clearInterval(interval) + stopFallbackPoll() + unsubscribe() } }, [client, connState, worktreeId]) ) diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx index 04aae01b0bc..9be5430c807 100644 --- a/mobile/src/source-control/MobileSourceControlBranchCard.tsx +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -55,23 +55,28 @@ export function MobileSourceControlBranchCard({ {unstagedCount} changed {stagedCount} staged {branchCount > 0 ? {branchCount} on branch : null} - {showConflict ? ( - - {conflictOperation} - {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={conflictBusy} - onPress={() => onAbortConflict(conflictOperation)} - > - - {mobileConflictAbortLabel(conflictOperation, conflictAborting)} - - - ) : null} - - ) : null} + {/* Own row so Abort never overflows past the card when counts are long. */} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [ + styles.abortButton, + conflictBusy && styles.abortButtonDisabled, + pressed && !conflictBusy && styles.abortPressed + ]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} {prChip ? : null} ) diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index a28504a5c53..885d8bf0c4d 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,4 +1,12 @@ -import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' +import { + ActivityIndicator, + Pressable, + ScrollView, + SectionList, + Text, + TextInput, + View +} from 'react-native' import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' @@ -13,7 +21,8 @@ type Props = { state: MobileSourceControlState } -// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. +// Changes tab: local file changes only — uncommitted (staged/unstaged) plus +// committed-on-branch vs base. PR conflicts and push status live elsewhere. export function MobileSourceControlContent({ state }: Props) { const { insets, @@ -49,6 +58,21 @@ export function MobileSourceControlContent({ state }: Props) { const shouldShowGenerateButton = stagedCount > 0 || generatingMessage const createPrHeroActive = createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst + const branchCompareFooter = ( + + ) return ( <> @@ -126,9 +150,15 @@ export function MobileSourceControlContent({ state }: Props) { {!hasVisibleChanges ? ( - No Changes + No local changes Working tree is clean. + ) : sections.length === 0 ? ( + // Why: RN SectionList with empty `sections` often skips ListFooterComponent, + // which hid "Committed on Branch" when only branch files remain. + + {branchCompareFooter} + ) : ( {section.data.length} )} - ListFooterComponent={ - - } + ListFooterComponent={branchCompareFooter} stickySectionHeadersEnabled={false} contentContainerStyle={styles.listContent} /> diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index ea766731840..d8dc20a1469 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -92,6 +92,7 @@ const baseStyles = StyleSheet.create({ }, countRow: { flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.sm }, @@ -99,29 +100,43 @@ const baseStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Separate line under counts — keeps Abort inside the card on narrow phones. conflictRow: { flexDirection: 'row', + flexWrap: 'wrap', alignItems: 'center', - gap: spacing.sm + gap: spacing.sm, + marginTop: spacing.sm, + alignSelf: 'flex-start', + maxWidth: '100%' }, conflictText: { color: colors.statusAmber, fontSize: typography.metaSize, textTransform: 'capitalize' }, + // Match bulk-action hit target so Abort reads as a real control, not a chip. abortButton: { - paddingHorizontal: spacing.sm, - paddingVertical: 2, + minHeight: 32, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, borderRadius: radii.button, borderWidth: 1, - borderColor: colors.statusAmber + borderColor: colors.statusAmber, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0 }, abortPressed: { - backgroundColor: colors.bgRaised + opacity: 0.75 + }, + abortButtonDisabled: { + opacity: 0.45 }, abortText: { color: colors.statusAmber, - fontSize: typography.metaSize, + fontSize: typography.bodySize, fontWeight: '600', textTransform: 'capitalize' }, diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.ts b/mobile/src/source-control/use-mobile-source-control-loaders.ts index 5a373ea5025..86055d21d3f 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.ts @@ -104,8 +104,18 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr return false } if (!baseRef) { - setBranchCompareState({ kind: 'idle' }) - return true + // Why: wiping a prior ready compare to idle makes Changes say "No + // Changes" even when commits still exist (e.g. after abort-merge refresh). + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { + kind: 'error', + message: 'Unable to resolve the base branch for comparison.' + } + }) + return false } const response = await client.sendRequest('git.branchCompare', { worktree: `id:${worktreeId}`, @@ -116,7 +126,12 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr } if (!response.ok) { if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { - setBranchCompareState({ kind: 'idle' }) + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'idle' } + }) return false } throw new Error(response.error?.message || 'Unable to load committed changes') diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index c9a8ca406b0..e50525f3c62 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -15,6 +15,7 @@ import { canOpenMobileBranchCompareDiff, formatMobileBranchCompareSummary } from './mobile-branch-compare' + import { buildMobileSourceControlSections, countStagedEntries, @@ -140,6 +141,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara })), [branchCompareCanOpen, branchCompareSection] ) + // Local changes only: dirty files + committed file diffs vs base (not PR/push). const shouldShowBranchCompareSection = branchEntries.length > 0 || branchCompareState.kind === 'loading' || diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 4b84e44081a..7eded2ef5ba 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,6 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' -import { WebView } from 'react-native-webview' -import type { WebViewMessageEvent } from 'react-native-webview' +import { WebView, type WebViewMessageEvent } from 'react-native-webview' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { @@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' +import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' type Props = TerminalWebViewProps @@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef(function } catch { return } + routeTerminalQueryReply(msg, onTerminalQueryReply) if (msg.type === 'web-ready') { confirmWebReady(true) @@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts new file mode 100644 index 00000000000..1b76a89574d --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply' + +function createClient() { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: '1', + ok: true, + result: { send: { accepted: true, bytesWritten: 6 } }, + _meta: { runtimeId: 'runtime-1' } + }) + } as unknown as Pick +} + +describe('sendMobileTerminalQueryReply', () => { + it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: true, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(true) + + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + }) + + it.each([ + ['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'], + ['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'], + ['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'], + ['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1'] + ])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes, + client, + clientId: 'mobile-1', + connected, + handle, + hostSupportsQueryReplyInput: true, + subscribedTerminals + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + // Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops + // unknown keys) and would treat the reply as floor-taking shell input. + it('does not send to a host that has not advertised query-reply input support', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: false, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/terminal/mobile-terminal-query-reply.ts b/mobile/src/terminal/mobile-terminal-query-reply.ts new file mode 100644 index 00000000000..4dd7ae7a8a5 --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.ts @@ -0,0 +1,51 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +type TerminalSubscriptionRegistry = { + has: (handle: string) => boolean +} + +type MobileTerminalQueryReplyOptions = { + bytes: string + client: Pick | null + clientId: string | null + connected: boolean + handle: string + hostSupportsQueryReplyInput: boolean + subscribedTerminals: TerminalSubscriptionRegistry +} + +export function sendMobileTerminalQueryReply({ + bytes, + client, + clientId, + connected, + handle, + hostSupportsQueryReplyInput, + subscribedTerminals +}: MobileTerminalQueryReplyOptions): Promise { + // Why: every subscribed mobile xterm suppresses main's responder, including + // hidden panes, so ownership follows the subscription rather than focus. + // Hosts without terminal.query-reply-input.v1 strip inputKind and would take + // reply bytes as floor-stealing shell input, so drop (pre-fix behavior). + if ( + !client || + !connected || + !hostSupportsQueryReplyInput || + !subscribedTerminals.has(handle) || + !isTerminalQueryReply(bytes) + ) { + return Promise.resolve(false) + } + + return client + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + inputKind: 'query-reply', + ...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {}) + }) + .then(isTerminalSendRpcAccepted, () => false) +} 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-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 808dece2648..63c155b7a23 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -28,6 +28,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalQueryReply?: (bytes: string) => void onTerminalTap?: () => void // Tap landed on a detected file path; RN resolves + opens it. onFileTap?: (pathText: string, line: number | null, column: number | null) => void diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index fcd5ea9cafa..69b2e6f6b51 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' @@ -215,7 +216,7 @@ window.onerror = function(msg) { var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; + var term = null; ${TERMINAL_QUERY_REPLY_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS} writeQueue.push(normalizeStatusDotPresentation(data)); } + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { resetWriteQueue(); @@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} if (!ready || !term || writesDraining || gen !== terminalGeneration) return; var next = nextQueuedWrite(); if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); var callbacks = afterDrainCallbacks; afterDrainCallbacks = []; for (var i = 0; i < callbacks.length; i++) callbacks[i](); @@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; terminalGeneration++; var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); cancelWebglContextRecovery(); webglAddon = null; ready = false; @@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} fontWeight: '300', fontWeightBold: '500', scrollback: 5000, - disableStdin: true, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, cursorBlink: false, cursorStyle: 'bar', cursorInactiveStyle: 'none', @@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} resetEvictionCounter(); cancelSelect(); attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); requestAnimationFrame(function() { if (gen !== terminalGeneration) return; @@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} write(msg.data); } else if (msg.type === 'clear') { terminalGeneration++; - resetWriteQueue(); + resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. statusDotPendingSelector = false; afterDrainCallbacks = []; writesDraining = false; diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts new file mode 100644 index 00000000000..a4ac34e5809 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-injected.ts @@ -0,0 +1,44 @@ +// Kept as one injectable unit so tests execute the same replay/generation gate +// that the WebView document runs, rather than a TypeScript reimplementation. +export const TERMINAL_QUERY_REPLY_JS = ` + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } +` diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts new file mode 100644 index 00000000000..af53772d481 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, _ref) => + React.createElement('WebView', props) + ) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +let renderer: ReactTestRenderer | null = null + +function postWebViewMessage(payload: Record): void { + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +describe('TerminalWebView query reply routing', () => { + afterEach(() => { + if (renderer) { + act(() => renderer?.unmount()) + renderer = null + } + vi.restoreAllMocks() + }) + + it('routes only complete terminal query replies to native', () => { + const onTerminalQueryReply = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) + }) + + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) + postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) + + expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) + expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.ts new file mode 100644 index 00000000000..cf79fb71b7a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.ts @@ -0,0 +1,14 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' + +export function routeTerminalQueryReply( + message: Record, + onTerminalQueryReply: ((bytes: string) => void) | undefined +): void { + if ( + message.type === 'terminal-data' && + typeof message.bytes === 'string' && + isTerminalQueryReply(message.bytes) + ) { + onTerminalQueryReply?.(message.bytes) + } +} diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts new file mode 100644 index 00000000000..4e8add33f2a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +type QueryReplyGate = { + forward: (data: string) => void + queueBoundary: (generation: number) => void + reset: () => void + resume: () => void + setGeneration: (generation: number) => void +} + +function createQueryReplyGate(notify: (message: unknown) => void): { + gate: QueryReplyGate + queuedBoundaries: Array<() => void> +} { + const queuedBoundaries: Array<() => void> = [] + const factory = new Function( + 'notify', + 'enqueueWriteBoundary', + `var terminalGeneration = 0; + ${TERMINAL_QUERY_REPLY_JS} + return { + forward: forwardTerminalDataReply, + queueBoundary: enqueueTerminalDataReplyBoundary, + reset: resetTerminalDataReplyAuthority, + resume: resumeTerminalDataReplyAuthority, + setGeneration: function(next) { terminalGeneration = next; } + };` + ) as ( + notify: (message: unknown) => void, + enqueueBoundary: (callback: () => void) => void + ) => QueryReplyGate + const gate = factory(notify, (callback) => queuedBoundaries.push(callback)) + return { gate, queuedBoundaries } +} + +describe('mobile terminal query replies', () => { + it('forwards xterm-generated data only after initial replay drains', () => { + const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + listenerIndex + ) + const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'forwardTerminalDataReply(data)', + listenerIndex + ) + + expect(listenerIndex).toBeGreaterThan(-1) + expect(enableIndex).toBeGreaterThan(listenerIndex) + expect(notifyIndex).toBeGreaterThan(listenerIndex) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') + expect(XTERM_WEBVIEW_SOURCE.html).toContain( + 'term.attachCustomKeyEventHandler(function() { return false; })' + ) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + }) + + it('mutes a replacement terminal until its own replay drains', () => { + const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData') + const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'resetTerminalDataReplyAuthority()', + initIndex + ) + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + disableIndex + ) + + expect(initIndex).toBeGreaterThan(-1) + expect(disableIndex).toBeGreaterThan(initIndex) + expect(enableIndex).toBeGreaterThan(disableIndex) + }) + + it('suppresses replay, then forwards live queries queued behind its boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + + queuedBoundaries[0]?.() + gate.forward('\x1b[2;2R') + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }]) + }) + + it('does not let a superseded generation reclaim reply authority', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.queueBoundary(1) + gate.setGeneration(2) + gate.reset() + + queuedBoundaries[0]?.() + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + }) + + it('restores reply authority when clear discards the replay boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + // A clear drops all queued writes, including the replay boundary. + queuedBoundaries.length = 0 + gate.resume() + gate.forward('\x1b[3;4R') + + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) + const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {") + const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf( + "} else if (msg.type === 'measure')", + clearStart + ) + expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain( + 'resumeTerminalDataReplyAuthority()' + ) + }) +}) 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/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts new file mode 100644 index 00000000000..d18739423f7 --- /dev/null +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class RuntimeEventTestSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = RuntimeEventTestSocket.CONNECTING + readonly OPEN = RuntimeEventTestSocket.OPEN + readonly CLOSING = RuntimeEventTestSocket.CLOSING + readonly CLOSED = RuntimeEventTestSocket.CLOSED + + readyState = RuntimeEventTestSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + sent: string[] = [] + + constructor(readonly endpoint: string) { + sockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + close(): void { + this.readyState = RuntimeEventTestSocket.CLOSED + this.onclose?.() + } + + open(): void { + this.readyState = RuntimeEventTestSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +type SentRequest = { id: string; method: string; params?: unknown } + +const sockets: RuntimeEventTestSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] { + return socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest) + .filter((request) => request.method === method) +} + +function connectReadyClient(): { client: RpcClient; socket: RuntimeEventTestSocket } { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = sockets[0]! + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + return { client, socket } +} + +function emitReady( + socket: RuntimeEventTestSocket, + requestId: string, + subscriptionId: string +): void { + socket.receive( + `encrypted:${JSON.stringify({ + id: requestId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId }, + _meta: { runtimeId: 'r1' } + })}` + ) +} + +describe('runtime client-event stream disposal', () => { + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + globalThis.WebSocket = originalWebSocket + vi.useRealTimers() + }) + + it('unsubscribes a stream disposed after ready', () => { + const { client, socket } = connectReadyClient() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, () => {}) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + emitReady(socket, request.id, 'runtime-events:test') + + unsubscribe() + + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:test' } }) + ]) + client.close() + }) + + it('keeps a tombstone and unsubscribes a stream disposed before ready', () => { + const { client, socket } = connectReadyClient() + const listener = vi.fn() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, listener) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + + unsubscribe() + emitReady(socket, request.id, 'runtime-events:late') + + expect(listener).not.toHaveBeenCalled() + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:late' } }) + ]) + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index b9d0c990261..014bea955cd 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -567,10 +567,10 @@ export function connect( const stream = streamListeners.get(response.id) if (stream && response.ok) { const result = (response as RpcSuccess).result - if (isBrowserScreencastReadyResult(result)) { + if (isStreamingSubscriptionReadyResult(result)) { stream.subscriptionId = result.subscriptionId if (stream.cancelled) { - sendBrowserScreencastUnsubscribe(result.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(response.id) return } @@ -947,8 +947,21 @@ export function connect( if (pendingBrowserScreencastRequestId === id) { pendingBrowserScreencastRequestId = null } + disposeServerSubscriptionStream(id, stream) + } + + function disposeRuntimeClientEventsStream(id: string): void { + const stream = streamListeners.get(id) + if (!stream || stream.method !== 'runtime.clientEvents.subscribe') { + return + } + disposeServerSubscriptionStream(id, stream) + } + + function disposeServerSubscriptionStream(id: string, stream: StreamRequest): void { + stream.cancelled = true if (stream.subscriptionId) { - sendBrowserScreencastUnsubscribe(stream.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(id) return } @@ -1022,6 +1035,24 @@ export function connect( }) } + function sendServerSubscriptionUnsubscribe(stream: StreamRequest): void { + if (!stream.subscriptionId) { + return + } + if (stream.method === 'browser.screencast') { + sendBrowserScreencastUnsubscribe(stream.subscriptionId) + return + } + if (stream.method === 'runtime.clientEvents.subscribe') { + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'runtime.clientEvents.unsubscribe', + params: { subscriptionId: stream.subscriptionId } + }) + } + } + openConnection() return { @@ -1120,6 +1151,10 @@ export function connect( disposeBrowserScreencastStream(id) return } + if (stream?.method === 'runtime.clientEvents.subscribe') { + disposeRuntimeClientEventsStream(id) + return + } if (stream?.method === 'terminal.subscribe') { // Why: the runtime registers cleanup under the composite key // `${terminal}:${clientId}` so two phones subscribing to the same @@ -1243,7 +1278,7 @@ function isTerminalSubscribedResult( ) } -function isBrowserScreencastReadyResult( +function isStreamingSubscriptionReadyResult( value: unknown ): value is { type: 'ready'; subscriptionId: string } { return ( diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 754545ed727..55e1adc660a 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "expo/tsconfig.base.json", "compilerOptions": { + // Mobile stays on TypeScript 6 so Expo/Metro can still use the JS + // compiler API (require('typescript').sys) during release bundling. + "baseUrl": ".", + "ignoreDeprecations": "6.0", "strict": true, - // TypeScript 7 resolves path targets relative to this config without baseUrl. "paths": { "@/*": ["./src/*"], "tweetnacl": ["./node_modules/tweetnacl"], 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/package.json b/package.json index e6870ba4102..6ed66c79734 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.135-rc.5", + "version": "1.4.138-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -93,6 +93,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": { @@ -177,6 +179,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b48ad9b4f29..e12b10d7464 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: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 @@ -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 @@ -263,6 +263,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -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'} @@ -3457,6 +3409,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 +4307,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 +4391,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 +4588,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 +4914,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 +5278,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==} @@ -6740,12 +6689,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 +6717,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 +6727,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 +6745,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 +6766,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 +6777,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 +6786,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 +6808,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 +6830,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 +6860,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 +6869,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 +6887,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 @@ -7127,7 +7011,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 +7032,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 @@ -8992,24 +8876,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: @@ -9369,8 +9253,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 +9281,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 @@ -10588,24 +10471,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: @@ -12019,7 +11889,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 +11959,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12689,7 +12558,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 +12572,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 +12657,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} 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..b7596f76cc5 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`, 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/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index d19d486e209..735a72a53cf 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') @@ -631,20 +691,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 +717,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 +841,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..d215fcb2e2e 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,6 +77,7 @@ type MessageSummary = { type?: string body?: string payload?: string | null + read?: number } function getOptionalStructuredMessagePayload( @@ -142,7 +147,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 +277,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,6 +347,12 @@ export const ORCHESTRATION_HANDLERS: Record = { const type = getOptionalStringFlag(flags, 'type') rejectLifecycleGroupRecipient(type, to) + // 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 } @@ -360,6 +365,9 @@ 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() }) printResult(result, json, (r) => { @@ -372,17 +380,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 +410,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 +423,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 +533,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 +543,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/selectors.ts b/src/cli/selectors.ts index a00f86ec1a6..93623967e57 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -39,7 +39,7 @@ 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:.` ) } 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..3eb022dd7ed 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 when sent from the dispatched pane (or when pane identity is unavailable); on older runtimes the sender must match the dispatch assignee handle, so avoid overriding --from.', '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/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index fabf508b141..2b734fd41ca 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -8,10 +8,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree- 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' @@ -307,7 +307,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) @@ -390,7 +390,7 @@ async function runFolderWorkspaceTitleAutoRename( return retry(`generation failed: ${generated.error}`) } - const newDisplayName = humanizeBranchSlug(generated.slug) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) deps.setDisplayName(worktreeId, newDisplayName) deps.setRenameError(worktreeId, null) deps.onRenamed(worktreeId) 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 bf3cf0c8f84..9e9676c2243 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -143,6 +143,89 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not infer an interrupt while a subagent child is still working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'review loop', + agentType: 'claude', + // Why: a working pane can be child-driven (lead already idle). + // Ctrl+C does not stop background children, so no terminal done + // may be inferred while one is still running. + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'review loop', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + vi.useRealTimers() + } + }) + + it('carries idle subagent rows through an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'wrap up', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'wrap up', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })] + }) + } finally { + vi.useRealTimers() + } + }) + it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -4959,13 +5042,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', () => { diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index da33b4231cb..f859277303d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -26,12 +26,14 @@ import { getEndpointFileName, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, + markClaudeLeadTurnInterrupted, MAX_PANE_KEY_LEN, normalizeHookPayload, parseFormEncodedBody, readRequestBody, resolveHookSource, preparePendingGrokResultDiscovery, + seedClaudeSubagentRosterFromSnapshots, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -253,6 +255,10 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } +// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`: +// OSC payloads never carry them, and including the field would make every +// hook-cached entry with child rows non-equivalent — the OSC ping would then +// apply and wipe the roster. Do not reuse this for hook-path comparisons. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, b: ParsedAgentStatusPayload @@ -308,6 +314,7 @@ function shouldKeepClaudePermissionVisible( if ( previous?.payload.agentType !== 'claude' || previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || next.payload.agentType !== 'claude' || next.payload.state !== 'working' ) { @@ -319,9 +326,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 } @@ -555,7 +561,20 @@ export class AgentHookServer { ) { return false } + // Why: a 'working' pane can be child-driven (lead already idle, background + // subagent running). Ctrl+C at the TUI does not stop background children, + // so inferring a terminal done here would wrongly retire live child rows; + // their own hook events keep the row truthful instead. + if (payload.subagents?.some((subagent) => subagent.state === 'working')) { + return false + } + // Why: keep the listener's Claude lead-turn record in sync — a later + // child lifecycle event would otherwise re-emit the stale pre-interrupt + // 'working' lead state and resurrect the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -566,7 +585,10 @@ export class AgentHookServer { state: 'done', prompt: payload.prompt, agentType, - interrupted: true + interrupted: true, + // Why: idle children are display state; dropping them on an inferred + // interrupt would blank the child rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) console.debug('[agent-hooks] inferred interrupted agent status', { @@ -1558,6 +1580,17 @@ export class AgentHookServer { const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + // Why: the in-memory subagent roster died with the previous process. + // Reseed it from the persisted snapshot so the next teammate-bearing + // Stop (whose task ids never match lifecycle ids) doesn't silently + // drop the replayed child rows. + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } hydrated += 1 } else { dropped += 1 diff --git a/src/main/ai-vault/local-log-tail-reader.test.ts b/src/main/ai-vault/local-log-tail-reader.test.ts new file mode 100644 index 00000000000..cc0c1889e40 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { LOCAL_LOG_TAIL_CHUNK_BYTES } from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from './local-log-tail-reader' + +const tempPaths: string[] = [] + +async function makeLog(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-log-tail-')) + tempPaths.push(directory) + const filePath = join(directory, 'session.jsonl') + await writeFile(filePath, content) + return filePath +} + +afterEach(async () => { + await Promise.all(tempPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('readLocalLogTailRange', () => { + it('reads only the requested appended range', async () => { + const filePath = await makeLog('first\nsecond\n') + const result = await readLocalLogTailRange(filePath, Buffer.byteLength('first\n')) + + expect(Buffer.from(result.contentBase64, 'base64').toString('utf8')).toBe('second\n') + expect(result.nextByteOffset).toBe(Buffer.byteLength('first\nsecond\n')) + expect(result.hasMore).toBe(false) + expect(result.reset).toBe(false) + }) + + it('caps each response and reports that more bytes remain', async () => { + const filePath = await makeLog('x'.repeat(LOCAL_LOG_TAIL_CHUNK_BYTES + 17)) + const result = await readLocalLogTailRange(filePath, 0) + + expect(Buffer.from(result.contentBase64, 'base64')).toHaveLength(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.nextByteOffset).toBe(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.hasMore).toBe(true) + }) + + it('requests a reset after truncation', async () => { + const filePath = await makeLog('first\nsecond\n') + const initial = await readLocalLogTailRange(filePath, 0) + await truncate(filePath, 2) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.nextByteOffset).toBe(0) + expect(result.fileSize).toBe(2) + }) + + it('requests a reset when the file is atomically replaced', async () => { + const filePath = await makeLog('old\n') + const initial = await readLocalLogTailRange(filePath, 0) + const replacement = `${filePath}.next` + await writeFile(replacement, 'new and longer\n') + await rename(replacement, filePath) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.fileIdentity).not.toBe(initial.fileIdentity) + }) +}) diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts new file mode 100644 index 00000000000..3045981e5c5 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.ts @@ -0,0 +1,78 @@ +import { open } from 'node:fs/promises' +import { + LOCAL_LOG_TAIL_CHUNK_BYTES, + type LocalLogTailReadResult +} from '../../shared/local-log-tail-types' + +export function localLogFileIdentity(stats: { + dev: number + ino: number + birthtimeMs: number +}): string { + // Why: inode catches atomic replacement on POSIX while birthtime covers + // platforms/filesystems that report an unusable inode value. + return `${stats.dev}:${stats.ino}:${stats.birthtimeMs}` +} + +export async function readLocalLogTailRange( + filePath: string, + fromByteOffset: number, + expectedIdentity?: string +): Promise { + if (!Number.isSafeInteger(fromByteOffset) || fromByteOffset < 0) { + throw new Error('Invalid local log tail byte offset') + } + + const handle = await open(filePath, 'r') + try { + const initialStats = await handle.stat() + if (!initialStats.isFile()) { + throw new Error('Local log tail target is not a file') + } + const fileIdentity = localLogFileIdentity(initialStats) + if ( + fromByteOffset > initialStats.size || + (expectedIdentity !== undefined && expectedIdentity !== fileIdentity) + ) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: initialStats.size, + fileIdentity, + hasMore: initialStats.size > 0, + reset: true + } + } + + const bytesToRead = Math.min(LOCAL_LOG_TAIL_CHUNK_BYTES, initialStats.size - fromByteOffset) + const buffer = Buffer.allocUnsafe(bytesToRead) + const { bytesRead } = + bytesToRead > 0 ? await handle.read(buffer, 0, bytesToRead, fromByteOffset) : { bytesRead: 0 } + const nextByteOffset = fromByteOffset + bytesRead + const finalStats = await handle.stat() + + // Why: truncation can race the ranged read on the same open handle. Do not + // let an offset beyond the new EOF become the baseline for later appends. + if (nextByteOffset > finalStats.size) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: finalStats.size, + fileIdentity, + hasMore: finalStats.size > 0, + reset: true + } + } + + return { + contentBase64: buffer.subarray(0, bytesRead).toString('base64'), + nextByteOffset, + fileSize: finalStats.size, + fileIdentity, + hasMore: nextByteOffset < finalStats.size, + reset: false + } + } finally { + await handle.close() + } +} diff --git a/src/main/ai-vault/session-scanner-codex-title-index.test.ts b/src/main/ai-vault/session-scanner-codex-title-index.test.ts new file mode 100644 index 00000000000..3c5b129b5fc --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-title-index.test.ts @@ -0,0 +1,140 @@ +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 { + _getCodexSessionIndexTitleCacheSizeForTest, + _hasCodexSessionIndexTitleCacheEntryForTest, + _readCachedCodexSessionIndexTitlesForTest, + _storeCodexSessionIndexTitleCacheEntryForTest, + readCodexSessionIndexTitle, + resetCodexSessionIndexTitleCacheForTests +} from './session-scanner-codex-title-index' + +const CACHE_LIMIT = 64 + +let tempRoots: string[] = [] + +afterEach(async () => { + resetCodexSessionIndexTitleCacheForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function createCodexHome(index: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-title-index-')) + tempRoots.push(root) + const codexHome = join(root, `codex-home-${index}`) + await mkdir(join(codexHome, 'sessions'), { recursive: true }) + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: `session-${index}`, thread_name: `Title ${index}` })}\n` + ) + return codexHome +} + +async function readTitle(codexHome: string, index: number): Promise { + return readCodexSessionIndexTitle( + join(codexHome, 'sessions', `session-${index}.jsonl`), + codexHome, + `session-${index}` + ) +} + +describe('codex session index title cache', () => { + it('caps cached title indexes by Codex home', async () => { + const homes: string[] = [] + + for (let index = 0; index < CACHE_LIMIT + 1; index++) { + const codexHome = await createCodexHome(index) + homes.push(codexHome) + expect(await readTitle(codexHome, index)).toBe(`Title ${index}`) + } + + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(CACHE_LIMIT) + expect(_hasCodexSessionIndexTitleCacheEntryForTest(homes[0])).toBe(false) + expect(_hasCodexSessionIndexTitleCacheEntryForTest(homes[CACHE_LIMIT])).toBe(true) + }) + + it('refreshes cache recency when an existing Codex home is reused', async () => { + const homes: string[] = [] + for (let index = 0; index < CACHE_LIMIT; index++) { + const codexHome = await createCodexHome(index) + homes.push(codexHome) + await readTitle(codexHome, index) + } + + await readTitle(homes[0], 0) + const extraHome = await createCodexHome(CACHE_LIMIT) + await readTitle(extraHome, CACHE_LIMIT) + + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(CACHE_LIMIT) + expect(_hasCodexSessionIndexTitleCacheEntryForTest(homes[0])).toBe(true) + expect(_hasCodexSessionIndexTitleCacheEntryForTest(homes[1])).toBe(false) + }) + + it('does not resurrect a pending cache hit after that home is evicted', async () => { + let resolveSlowTitles: (titles: Map) => void = () => {} + const slowTitles = new Promise>((resolve) => { + resolveSlowTitles = resolve + }) + _storeCodexSessionIndexTitleCacheEntryForTest('slow-home', 'stable', slowTitles) + const pendingHit = _readCachedCodexSessionIndexTitlesForTest('slow-home', 'stable') + + for (let index = 0; index < CACHE_LIMIT; index += 1) { + _storeCodexSessionIndexTitleCacheEntryForTest( + `replacement-home-${index}`, + `signature-${index}`, + Promise.resolve(new Map([[`session-${index}`, `Title ${index}`]])) + ) + } + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(CACHE_LIMIT) + expect(_hasCodexSessionIndexTitleCacheEntryForTest('slow-home')).toBe(false) + + const titles = new Map([['slow-session', 'Slow title']]) + resolveSlowTitles(titles) + + await expect(pendingHit).resolves.toBe(titles) + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(CACHE_LIMIT) + expect(_hasCodexSessionIndexTitleCacheEntryForTest('slow-home')).toBe(false) + }) + + it('does not replace a newer same-home entry when an older cache hit resolves', async () => { + let resolveOldTitles: (titles: Map) => void = () => {} + const oldTitles = new Promise>((resolve) => { + resolveOldTitles = resolve + }) + _storeCodexSessionIndexTitleCacheEntryForTest('same-home', 'old', oldTitles) + const pendingOldHit = _readCachedCodexSessionIndexTitlesForTest('same-home', 'old') + + const newTitles = new Map([['new-session', 'New title']]) + _storeCodexSessionIndexTitleCacheEntryForTest('same-home', 'new', Promise.resolve(newTitles)) + const resolvedOldTitles = new Map([['old-session', 'Old title']]) + resolveOldTitles(resolvedOldTitles) + + await expect(pendingOldHit).resolves.toBe(resolvedOldTitles) + await expect(_readCachedCodexSessionIndexTitlesForTest('same-home', 'new')).resolves.toBe( + newTitles + ) + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(1) + }) + + it('preserves a newer same-home entry when an older cache hit rejects', async () => { + let rejectOldTitles: (error: Error) => void = () => {} + const oldTitles = new Promise>((_resolve, reject) => { + rejectOldTitles = reject + }) + _storeCodexSessionIndexTitleCacheEntryForTest('same-home', 'old', oldTitles) + const pendingOldHit = _readCachedCodexSessionIndexTitlesForTest('same-home', 'old') + + const newTitles = new Map([['new-session', 'New title']]) + _storeCodexSessionIndexTitleCacheEntryForTest('same-home', 'new', Promise.resolve(newTitles)) + rejectOldTitles(new Error('old read failed')) + + await expect(pendingOldHit).rejects.toThrow('old read failed') + await expect(_readCachedCodexSessionIndexTitlesForTest('same-home', 'new')).resolves.toBe( + newTitles + ) + expect(_getCodexSessionIndexTitleCacheSizeForTest()).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index d22f964f538..8ef2d5645cb 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -8,6 +8,9 @@ import { extractString, normalizeTitleText, parseJsonObject } from './session-sc // carry no title of their own, so parsers look the thread name up here. const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' +// Why: custom and WSL Codex homes can vary over a long-lived main process; +// each cached home can retain a full session_index title map. +const CODEX_SESSION_INDEX_TITLE_CACHE_MAX = 64 type CodexSessionIndexTitleCacheEntry = { signature: string @@ -16,6 +19,36 @@ type CodexSessionIndexTitleCacheEntry = { const codexSessionIndexTitleCache = new Map>() +export function resetCodexSessionIndexTitleCacheForTests(): void { + codexSessionIndexTitleCache.clear() +} + +export function _getCodexSessionIndexTitleCacheSizeForTest(): number { + return codexSessionIndexTitleCache.size +} + +export function _hasCodexSessionIndexTitleCacheEntryForTest(codexHome: string): boolean { + return codexSessionIndexTitleCache.has(codexHome) +} + +export function _storeCodexSessionIndexTitleCacheEntryForTest( + codexHome: string, + signature: string, + titles: Promise> +): void { + storeCodexSessionIndexTitleCacheEntry( + codexHome, + titles.then((resolvedTitles) => ({ signature, titles: resolvedTitles })) + ) +} + +export function _readCachedCodexSessionIndexTitlesForTest( + codexHome: string, + signature: string +): Promise | undefined> { + return readCachedCodexSessionIndexTitles(codexHome, signature) +} + export async function readCodexSessionIndexTitle( sessionFilePath: string, codexHome: string | null, @@ -50,22 +83,54 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise ({ signature, titles })) - codexSessionIndexTitleCache.set(codexHome, pending) + storeCodexSessionIndexTitleCacheEntry(codexHome, pending) return (await pending).titles } +async function readCachedCodexSessionIndexTitles( + codexHome: string, + signature: string +): Promise | undefined> { + const cached = codexSessionIndexTitleCache.get(codexHome) + if (!cached) { + return undefined + } + const entry = await cached + if (entry.signature !== signature) { + return undefined + } + // Why: another scan can evict or replace this Promise while it resolves; + // only the still-current entry may refresh recency without bypassing the cap. + if (codexSessionIndexTitleCache.get(codexHome) === cached) { + codexSessionIndexTitleCache.delete(codexHome) + codexSessionIndexTitleCache.set(codexHome, cached) + } + return entry.titles +} + +function storeCodexSessionIndexTitleCacheEntry( + codexHome: string, + pending: Promise +): void { + codexSessionIndexTitleCache.delete(codexHome) + codexSessionIndexTitleCache.set(codexHome, pending) + if (codexSessionIndexTitleCache.size > CODEX_SESSION_INDEX_TITLE_CACHE_MAX) { + const oldest = codexSessionIndexTitleCache.keys().next() + if (!oldest.done) { + codexSessionIndexTitleCache.delete(oldest.value) + } + } +} + async function readCodexSessionIndexTitlesFromDisk( indexPath: string ): Promise> { 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/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..c0161dd94e8 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -0,0 +1,330 @@ +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 }) { + 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(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..504c862a5b8 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.ts @@ -0,0 +1,227 @@ +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({ + 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 ed77f48e6e0..6400efe7357 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -255,6 +255,9 @@ describe('ClaudeHookService.installRemote', () => { 'UserPromptSubmit', 'Stop', 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'TeammateIdle', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index cb754fce5d0..0c7012b383a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -32,6 +32,14 @@ export const CLAUDE_EVENTS = [ // Why: OpenClaude skips normal Stop hooks after API/model errors and emits // StopFailure instead; without this hook Orca leaves the turn spinning. { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps + // a pane 'working' while background children outlive the lead's turn. + // TeammateIdle is required because idle-but-alive teammates still report + // status "running" in Stop's background_tasks. + // Older Claude builds ignore unregistered event names (StopFailure precedent). + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 4ce919cef6b..596cb8bbfc8 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 packaged Windows command path to resources/bin/orca.exe', async () => { const fixture = await makeFixture() const localAppDataPath = fixture.root const resourcesPath = join(fixture.root, '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', @@ -1113,7 +1195,7 @@ describe('CliInstaller', () => { const status = await installer.getStatus() expect(status.commandPath).toBe( - join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') + join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') ) }) @@ -1121,8 +1203,8 @@ describe('CliInstaller', () => { 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 bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') + const bundledContent = 'native launcher' await mkdir(dirname(bundledLauncher), { recursive: true }) await writeFile(bundledLauncher, bundledContent, 'utf8') diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6765d8249b1..da8a3cc076a 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,7 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') + return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') } return null @@ -823,7 +823,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 +831,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 +1064,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 +1183,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/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/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9958e0fe867..afee3f9dc53 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -142,7 +142,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, @@ -496,6 +495,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 +523,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..8c4779573fc 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -147,7 +147,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.ts b/src/main/codex/hook-service.ts index 97142a4a242..0ad81dede26 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -30,6 +30,7 @@ import { computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + normalizeCodexProjectPathForLookup, normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, @@ -1010,6 +1011,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 +1024,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 +1040,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/crash-reporting/crash-feedback-diagnostic-bundle.ts b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts new file mode 100644 index 00000000000..4068dcdb834 --- /dev/null +++ b/src/main/crash-reporting/crash-feedback-diagnostic-bundle.ts @@ -0,0 +1,119 @@ +import os from 'node:os' +import { app } from 'electron' +import { + sanitizeCrashReportString, + type CrashReportDiagnosticBundle +} from '../../shared/crash-reporting' +import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability' +import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint' +import type { FeedbackDiagnosticBundleAttachment, FeedbackSubmitResult } from '../ipc/feedback' + +const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60 + +export type CrashDiagnosticBundleAttachment = { + readonly diagnosticBundle: CrashReportDiagnosticBundle + readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment +} + +function formatUnknownError(error: unknown): string { + return sanitizeCrashReportString(error instanceof Error ? error.message : String(error)) +} + +function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment { + return { + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log upload skipped by user' + } + } +} + +function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment { + const status = getDiagnosticsStatus() + if (!status.bundleEnabled) { + return { + diagnosticBundle: { + status: 'not_uploaded', + reason: status.disabledReason ?? 'diagnostic bundle collection is disabled' + } + } + } + + let bundle: ReturnType + try { + bundle = collectDiagnosticBundle({ + appVersion: app.getVersion(), + platform: os.platform(), + arch: os.arch(), + osRelease: os.release(), + orcaChannel: resolveDiagnosticOrcaChannel(), + // Why: Help > Report Crash is often used after relaunch, long after the + // default 30 minute support bundle window would miss the failure context. + lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES + }) + } catch (error) { + return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } } + } + + return { + diagnosticBundle: { + status: 'attached', + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + }, + feedbackDiagnosticBundle: { + bundleSubmissionId: bundle.bundleSubmissionId, + content: bundle.payload, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } + } +} + +export function prepareCrashDiagnosticBundle( + includeDiagnosticLogs: boolean +): CrashDiagnosticBundleAttachment { + return includeDiagnosticLogs + ? collectCrashDiagnosticBundleAttachment() + : skippedCrashDiagnosticBundle() +} + +export function diagnosticBundleForReportOnlyRetry( + attachment: CrashDiagnosticBundleAttachment +): CrashReportDiagnosticBundle | undefined { + const bundle = attachment.feedbackDiagnosticBundle + if (!bundle) { + return undefined + } + return { + status: 'not_uploaded', + reason: 'diagnostic log attachment could not be sent; report retried without logs', + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } +} + +export function resolveSubmittedDiagnosticBundle( + attachment: CrashDiagnosticBundleAttachment, + result: FeedbackSubmitResult +): CrashReportDiagnosticBundle { + const bundle = attachment.feedbackDiagnosticBundle + if (!bundle) { + return attachment.diagnosticBundle + } + const failure = + result.diagnosticBundleFailure ?? + (!result.ok ? { status: result.status, error: result.error } : undefined) + if (!failure) { + return attachment.diagnosticBundle + } + return { + status: 'not_uploaded', + reason: `diagnostic log attachment failed: ${formatUnknownError(failure.error)}`, + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } +} diff --git a/src/main/crash-reporting/crash-report-copy-text.test.ts b/src/main/crash-reporting/crash-report-copy-text.test.ts new file mode 100644 index 00000000000..3a8ec88803c --- /dev/null +++ b/src/main/crash-reporting/crash-report-copy-text.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { formatCrashReportCopyText } from './crash-report-copy-text' + +describe('formatCrashReportCopyText', () => { + it('appends only sanitized report and diagnostic omission failure fields', () => { + const text = formatCrashReportCopyText('[Crash Report]', { + error: 'proxy failed at C:\\Users\\alice\\Orca', + diagnosticContext: { + status: 'not_uploaded', + reason: 'timeout token=super-secret-value\n- Forged field: hidden', + internalEndpointError: 'must-not-cross-copy-boundary' + }, + internalTransportFailure: 'must-not-cross-copy-boundary' + }) + + expect(text).toContain('Submission failure:') + expect(text).toContain('Report error: proxy failed at [redacted-path]') + expect(text).toContain('Diagnostic logs not uploaded: timeout token=[redacted]') + expect(text).not.toContain('\n- Forged field') + expect(text).not.toContain('alice') + expect(text).not.toContain('must-not-cross-copy-boundary') + }) + + it('includes a sanitized uploaded ticket without accepting unrelated fields', () => { + const text = formatCrashReportCopyText('[Crash Report]', { + error: 'report link failed', + diagnosticContext: { + status: 'uploaded', + ticketId: 'ticket-123', + bundleSubmissionId: 'not-allow-listed' + } + }) + + expect(text).toContain('Diagnostic ticket uploaded but not linked: ticket-123') + expect(text).not.toContain('not-allow-listed') + }) + + it('leaves the report unchanged when failure context is invalid', () => { + expect(formatCrashReportCopyText('[Crash Report]', { error: '' })).toBe('[Crash Report]') + }) +}) diff --git a/src/main/crash-reporting/crash-report-copy-text.ts b/src/main/crash-reporting/crash-report-copy-text.ts new file mode 100644 index 00000000000..681b8bd9e85 --- /dev/null +++ b/src/main/crash-reporting/crash-report-copy-text.ts @@ -0,0 +1,53 @@ +import { + sanitizeCrashReportString, + type CrashReportCopySubmissionFailure +} from '../../shared/crash-reporting' + +function sanitizedNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const sanitized = sanitizeCrashReportString(value.replace(/[\r\n]+/g, ' ')).trim() + return sanitized || null +} + +function normalizeSubmissionFailure(value: unknown): CrashReportCopySubmissionFailure | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + const error = sanitizedNonEmptyString(record.error) + if (!error) { + return null + } + + const rawContext = record.diagnosticContext + if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) { + return { error } + } + const context = rawContext as Record + if (context.status === 'uploaded') { + const ticketId = sanitizedNonEmptyString(context.ticketId) + return ticketId ? { error, diagnosticContext: { status: 'uploaded', ticketId } } : { error } + } + if (context.status === 'not_uploaded') { + const reason = sanitizedNonEmptyString(context.reason) + return reason ? { error, diagnosticContext: { status: 'not_uploaded', reason } } : { error } + } + return { error } +} + +export function formatCrashReportCopyText(baseText: string, submissionFailure: unknown): string { + const failure = normalizeSubmissionFailure(submissionFailure) + if (!failure) { + return baseText + } + + const lines = [baseText, '', 'Submission failure:', `- Report error: ${failure.error}`] + if (failure.diagnosticContext?.status === 'uploaded') { + lines.push(`- Diagnostic ticket uploaded but not linked: ${failure.diagnosticContext.ticketId}`) + } else if (failure.diagnosticContext?.status === 'not_uploaded') { + lines.push(`- Diagnostic logs not uploaded: ${failure.diagnosticContext.reason}`) + } + return lines.join('\n') +} diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index 2dc9a87aa55..1a14a9fa2e6 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -1,7 +1,17 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { grantDirAclAsyncMock } = vi.hoisted(() => ({ + grantDirAclAsyncMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../win32-utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, grantDirAclAsync: grantDirAclAsyncMock } +}) + import { CrashReportStore } from './crash-report-store' import type { CrashReportCreateInput } from '../../shared/crash-reporting' @@ -38,6 +48,9 @@ function input(reason = 'crashed'): CrashReportCreateInput { } afterEach(async () => { + vi.restoreAllMocks() + grantDirAclAsyncMock.mockReset() + grantDirAclAsyncMock.mockResolvedValue(undefined) await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))) }) @@ -134,4 +147,72 @@ describe('CrashReportStore', () => { await expect(store.listRecent()).resolves.toHaveLength(5) }) + + it('waits for an in-flight crash write before reading the pending report', async () => { + const { store } = await createStore() + + const recordPromise = store.record(input()) + const pendingPromise = store.getLatestPending() + const report = await recordPromise + + await expect(pendingPromise).resolves.toMatchObject({ id: report.id, status: 'pending' }) + }) + + it('repairs the Windows userData ACL and retries a denied crash write', async () => { + const { store, filePath } = await createStore() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const permissionError = Object.assign(new Error('permission denied'), { code: 'EPERM' }) + const writeFileSpy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(permissionError) + + const report = await store.record(input()) + + expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath)) + expect(writeFileSpy).toHaveBeenCalledTimes(2) + await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id }) + }) + + it.each(['EPERM', 'EACCES', 'EBUSY'] as const)( + 'recovers a pending report after a transient Windows %s read failure', + async (code) => { + const { store, filePath } = await createStore() + const report = await store.record(input()) + const reloaded = new CrashReportStore(filePath) + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const readError = Object.assign(new Error('temporary read failure'), { code }) + const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError) + + await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id }) + + expect(readFileSpy).toHaveBeenCalledTimes(2) + if (code === 'EBUSY') { + expect(grantDirAclAsyncMock).not.toHaveBeenCalled() + } else { + expect(grantDirAclAsyncMock).toHaveBeenCalledOnce() + expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath)) + } + } + ) + + it('retries a transient Windows rename lock', async () => { + const { store } = await createStore() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const busyError = Object.assign(new Error('file busy'), { code: 'EBUSY' }) + const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(busyError) + + await expect(store.record(input())).resolves.toMatchObject({ status: 'pending' }) + + expect(renameSpy).toHaveBeenCalledTimes(2) + expect(grantDirAclAsyncMock).not.toHaveBeenCalled() + }) + + it('removes its temp file after a terminal write failure', async () => { + const { store, filePath } = await createStore() + const ioError = Object.assign(new Error('disk error'), { code: 'EIO' }) + vi.spyOn(fs, 'rename').mockRejectedValueOnce(ioError) + + await expect(store.record(input())).rejects.toBe(ioError) + + const entries = await fs.readdir(path.dirname(filePath)) + expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([]) + }) }) diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index e1791398e7b..35f9eb5cb48 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { app } from 'electron' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' import { formatCrashReportText, sanitizeCrashReportBreadcrumbs, @@ -14,6 +15,7 @@ import { const MAX_REPORTS = 5 const RELATED_CRASH_WINDOW_MS = 5_000 +const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250] type CrashReportFile = { reports: CrashReportRecord[] @@ -37,6 +39,46 @@ function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRe ) } +function isRetryableWindowsFileOperationError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY' +} + +async function wait(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +async function runCrashReportFileOperationWithWindowsRecovery( + directory: string, + operation: () => Promise +): Promise { + let repairedAcl = false + for (let attempt = 0; ; attempt += 1) { + try { + return await operation() + } catch (error) { + if ( + process.platform !== 'win32' || + !isRetryableWindowsFileOperationError(error) || + attempt >= WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS.length + ) { + throw error + } + if (!repairedAcl && isPermissionError(error)) { + repairedAcl = true + try { + // Why: Chromium can reset userData ACLs before startup capture or + // recovery, so both the crash write and next prompt must repair it. + await grantDirAclAsync(directory) + } catch { + // The bounded retry below still handles transient file locks. + } + } + await wait(WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS[attempt]) + } + } +} + export class CrashReportStore { private writeChain = Promise.resolve() @@ -135,7 +177,9 @@ export class CrashReportStore { mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }> ): Promise { const run = this.writeChain.then(async () => { - const reports = await this.readReports() + // Why: awaiting writeChain from inside its own callback would deadlock; + // this writer already has exclusive ownership and can read disk directly. + const reports = await this.readReportsFromDisk() const { reports: nextReports, result } = await mutate(reports) await this.writeReports(nextReports) return result @@ -148,8 +192,18 @@ export class CrashReportStore { } private async readReports(): Promise { + // Why: renderer recovery can query the one-shot startup prompt while the + // crash write is still in flight. Wait so a successful capture is visible. + await this.writeChain + return this.readReportsFromDisk() + } + + private async readReportsFromDisk(): Promise { try { - const raw = await fs.readFile(this.filePath, 'utf8') + const raw = await runCrashReportFileOperationWithWindowsRecovery( + path.dirname(this.filePath), + () => fs.readFile(this.filePath, 'utf8') + ) const parsed = JSON.parse(raw) as Partial return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : [] } catch (error) { @@ -161,9 +215,18 @@ export class CrashReportStore { } private async writeReports(reports: CrashReportRecord[]): Promise { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + const directory = path.dirname(this.filePath) const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp` - await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') - await fs.rename(tmpPath, this.filePath) + try { + await runCrashReportFileOperationWithWindowsRecovery(directory, async () => { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') + await fs.rename(tmpPath, this.filePath) + }) + } finally { + // Why: disk-full and terminal rename failures must not accumulate a new + // orphaned multi-report temp file after every crash. + await fs.rm(tmpPath, { force: true }).catch(() => {}) + } } } diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.test.ts b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts new file mode 100644 index 00000000000..78c04f44d6b --- /dev/null +++ b/src/main/crash-reporting/durable-crash-breadcrumb.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer' + +type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + const flushMock = vi.fn() + return { + records, + flushMock, + push: (record) => records.push(record), + flush: flushMock, + close: vi.fn() + } +} + +let sink: CapturingSink + +beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + _resetTracerForTests() + clearCrashBreadcrumbsForTest() +}) + +describe('recordDurableCrashBreadcrumb', () => { + it('records, traces, and flushes a sanitized crash breadcrumb', () => { + recordDurableCrashBreadcrumb('process_gone_suppressed', { + source: 'renderer', + path: 'C:\\Users\\alice\\project' + }) + + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: { source: 'renderer', path: '[redacted-path]' } + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + attributes: expect.objectContaining({ + kind: 'crash-breadcrumb', + 'breadcrumb.name': 'process_gone_suppressed' + }), + exit: { _tag: 'Success' } + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('records persistence failures as failed, flushed spans', () => { + recordDurableCrashBreadcrumb( + 'crash_report_persist_failed', + { errorCode: 'EPERM' }, + 'EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca' + ) + + expect(sink.records).toEqual([ + expect.objectContaining({ + exit: expect.objectContaining({ _tag: 'Failure', cause: expect.stringContaining('EPERM') }) + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain('alice') + expect(sink.flushMock).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts new file mode 100644 index 00000000000..14cca5e1bc6 --- /dev/null +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -0,0 +1,33 @@ +import { + sanitizeCrashReportDetails, + sanitizeCrashReportString, + type CrashReportBreadcrumbData +} from '../../shared/crash-reporting' +import { flushActiveSink, startSpan } from '../observability/tracer' +import { recordCrashBreadcrumb } from './crash-breadcrumb-store' + +export function recordDurableCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData, + failureCause?: string +): void { + const sanitizedName = sanitizeCrashReportString(name) + const sanitizedData = data ? sanitizeCrashReportDetails(data) : undefined + recordCrashBreadcrumb(sanitizedName, sanitizedData) + + const span = startSpan('crash.breadcrumb', { + attributes: { + kind: 'crash-breadcrumb', + 'breadcrumb.name': sanitizedName, + ...(sanitizedData ? { 'breadcrumb.data': sanitizedData } : {}) + } + }) + if (failureCause) { + span.fail(sanitizeCrashReportString(failureCause, 1_000)) + } else { + span.end() + } + // Why: these breadcrumbs explain a missing crash record; losing one to the + // normal trace batching window would recreate the diagnostic blind spot. + flushActiveSink() +} diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 50740f22add..78522b76617 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -366,19 +366,22 @@ describe('shouldRecoverRendererAfterProcessGone', () => { ).toBe(false) }) - it('does not recover renderer startup and security launch failures', () => { + it('recovers transient renderer launch failures', () => { expect( shouldRecoverRendererAfterProcessGone({ reason: 'launch-failed', expectedTeardown: 'none' }) - ).toBe(false) + ).toBe(true) expect( shouldRecoverRendererAfterProcessGone({ reason: 'launch-failed', expectedTeardown: 'renderer-reload' }) - ).toBe(false) + ).toBe(true) + }) + + it('does not recover renderer integrity failures', () => { expect( shouldRecoverRendererAfterProcessGone({ reason: 'integrity-failure', diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index c9042ede715..d3f363d1332 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -11,7 +11,7 @@ const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([ 'video_capture.mojom.VideoCaptureService' ]) const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed']) -const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure', 'launch-failed']) +const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure']) function isWindowsControlTerminationExitCode(exitCode: number | null): boolean { if (exitCode === null) { @@ -95,8 +95,9 @@ export function shouldRecoverRendererAfterProcessGone({ if (expectedTeardown === 'app-shutdown') { return false } - // Why: these mean Chromium could not start or trust the renderer process; - // retrying the same BrowserWindow load can loop indefinitely on Windows. + // Why: an integrity failure means Chromium cannot trust the renderer, so a + // reload cannot safely recover it. Launch failures can be transient and are + // bounded by the caller's renderer-recovery circuit breaker. if (NON_RECOVERABLE_RENDERER_REASONS.has(reason)) { return false } diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index 6beebfd4926..c4a2dc714fc 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -11,6 +11,27 @@ describe('ProcessGoneDedupe', () => { expect(dedupe.shouldRecord(key, 3_000)).toBe(true) }) + it('allows an immediate retry after a failed claim is released', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + const claim = dedupe.tryClaim('renderer', 1_000) + + expect(claim).not.toBeNull() + expect(dedupe.tryClaim('renderer', 1_001)).toBeNull() + dedupe.release(claim!) + expect(dedupe.tryClaim('renderer', 1_001)).not.toBeNull() + }) + + it('does not release a newer claim when an older persistence attempt fails late', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + const oldClaim = dedupe.tryClaim('renderer', 1_000) + const currentClaim = dedupe.tryClaim('renderer', 3_000) + + expect(oldClaim).not.toBeNull() + expect(currentClaim).not.toBeNull() + dedupe.release(oldClaim!) + expect(dedupe.tryClaim('renderer', 3_001)).toBeNull() + }) + it('prunes stale keys outside the dedupe window', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index eb746a18e57..b35cccc6576 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -6,10 +6,19 @@ type ProcessGoneDedupeOptions = { maxKeys?: number } +export type ProcessGoneDedupeClaim = { + readonly key: string +} + +type ProcessGoneDedupeEntry = { + readonly recordedAt: number + readonly claim: ProcessGoneDedupeClaim +} + export class ProcessGoneDedupe { private readonly windowMs: number private readonly maxKeys: number - private readonly recentKeys = new Map() + private readonly recentKeys = new Map() constructor(options: ProcessGoneDedupeOptions = {}) { this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS @@ -17,19 +26,33 @@ export class ProcessGoneDedupe { } shouldRecord(key: string, now = Date.now()): boolean { + return this.tryClaim(key, now) !== null + } + + tryClaim(key: string, now = Date.now()): ProcessGoneDedupeClaim | null { this.prune(now) const previous = this.recentKeys.get(key) - if (previous !== undefined && now - previous < this.windowMs) { - return false + if (previous && now - previous.recordedAt < this.windowMs) { + return null } + const claim = { key } // Why: process-gone tuples come from Electron and can vary by exit code; // keep the short dedupe window without retaining stale tuples forever. this.recentKeys.delete(key) - this.recentKeys.set(key, now) + this.recentKeys.set(key, { recordedAt: now, claim }) this.prune(now) - return true + return claim + } + + release(claim: ProcessGoneDedupeClaim): void { + const current = this.recentKeys.get(claim.key) + // Why: an old failed write must not erase a newer claim for the same + // renderer after the dedupe window expires or bounded entries are evicted. + if (current?.claim === claim) { + this.recentKeys.delete(claim.key) + } } get size(): number { @@ -37,8 +60,8 @@ export class ProcessGoneDedupe { } private prune(now: number): void { - for (const [key, recordedAt] of this.recentKeys) { - if (now - recordedAt >= this.windowMs) { + for (const [key, entry] of this.recentKeys) { + if (now - entry.recordedAt >= this.windowMs) { this.recentKeys.delete(key) } } diff --git a/src/main/crash-reporting/process-gone-diagnostics.test.ts b/src/main/crash-reporting/process-gone-diagnostics.test.ts index 769ed618766..e179cec86f3 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.test.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.test.ts @@ -71,6 +71,7 @@ describe('process gone diagnostics', () => { processType: 'Utility', reason: 'killed', exitCode: 1, + expectedTeardown: 'app-shutdown', details: { name: 'Network Service', serviceName: 'network.mojom.NetworkService', @@ -82,6 +83,7 @@ describe('process gone diagnostics', () => { processType: 'Utility', reason: 'killed', exitCode: 1, + expectedTeardown: 'app-shutdown', name: 'Network Service', serviceName: 'network.mojom.NetworkService' }) diff --git a/src/main/crash-reporting/process-gone-diagnostics.ts b/src/main/crash-reporting/process-gone-diagnostics.ts index 42c8cad3125..fd93c48b88a 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.ts @@ -4,6 +4,7 @@ import { type CrashReportBreadcrumbData, type CrashReportDetailValue } from '../../shared/crash-reporting' +import type { ExpectedTeardownScope } from './process-gone-classification' type ProcessMetricLike = { pid?: unknown @@ -124,19 +125,22 @@ export function buildSuppressedProcessGoneBreadcrumbData({ processType, reason, exitCode, + expectedTeardown, details }: { source: 'renderer' | 'child' processType: string reason: string exitCode: number | null + expectedTeardown: ExpectedTeardownScope details: Record }): CrashReportBreadcrumbData { const breadcrumb: CrashReportBreadcrumbData = { source, processType, reason, - exitCode + exitCode, + expectedTeardown } const name = safeString(details.name) if (name) { diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts new file mode 100644 index 00000000000..94a67982fa8 --- /dev/null +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getVersion: () => '1.2.3-test', + getAppMetrics: () => [] + } +})) + +import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { ProcessGoneDedupe } from './process-gone-dedupe' +import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer' + +type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + const flushMock = vi.fn() + return { + records, + flushMock, + push: (record) => records.push(record), + flush: flushMock, + close: vi.fn() + } +} + +function event(overrides: Partial = {}): ProcessGoneCrashEvent { + return { + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + expectedTeardown: 'none', + details: { processType: 'renderer' }, + ...overrides + } +} + +let sink: CapturingSink + +beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + vi.restoreAllMocks() + _resetTracerForTests() + clearCrashBreadcrumbsForTest() +}) + +describe('recordProcessGoneCrash', () => { + it('durably records when the crash report store is unavailable', () => { + recordProcessGoneCrash(null, event(), new ProcessGoneDedupe()) + + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'crash_report_store_unavailable', + data: expect.objectContaining({ + source: 'renderer', + expectedTeardown: 'none' + }) + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('durably records why an expected renderer teardown was suppressed', () => { + const record = vi.fn() + + recordProcessGoneCrash( + { record } as never, + event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload' }), + new ProcessGoneDedupe() + ) + + expect(record).not.toHaveBeenCalled() + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ expectedTeardown: 'renderer-reload' }) + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + attributes: expect.objectContaining({ + 'breadcrumb.name': 'process_gone_suppressed' + }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('persists a report and flushes the process-gone trace before recovery', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ source: 'renderer', reason: 'crashed', exitCode: 5 }) + ) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'electron.process_gone', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + + it('still persists the report when the forced trace flush fails', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + sink.flushMock.mockImplementation(() => { + throw new Error('trace disk unavailable') + }) + + expect(() => + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + ).not.toThrow() + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + }) + + it('still persists the report when the trace sink handoff fails', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + sink.push = () => { + throw new Error('trace rotation failed') + } + + expect(() => + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + ).not.toThrow() + await vi.waitFor(() => expect(record).toHaveBeenCalledOnce()) + }) + + it('durably records a sanitized crash-report persistence failure', async () => { + const persistError = Object.assign( + new Error('EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca\\crash-reports.json'), + { code: 'EPERM' } + ) + const record = vi.fn().mockRejectedValue(persistError) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => { + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'crash_report_persist_failed', + data: expect.objectContaining({ errorCode: 'EPERM' }) + }) + ]) + ) + }) + expect(sink.records).toHaveLength(2) + expect(sink.records[1]).toEqual( + expect.objectContaining({ + name: 'crash.breadcrumb', + exit: expect.objectContaining({ _tag: 'Failure' }) + }) + ) + expect(JSON.stringify(sink.records)).not.toContain('alice') + expect(sink.flushMock).toHaveBeenCalledTimes(2) + }) + + it('keeps null persistence rejections inside the fail-open diagnostic path', async () => { + const record = vi.fn().mockRejectedValue(null) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe()) + + await vi.waitFor(() => + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'crash_report_persist_failed', + data: expect.objectContaining({ errorName: 'object', errorMessage: 'null' }) + }) + ]) + ) + ) + }) + + it('allows the same renderer crash to retry after persistence fails', async () => { + const record = vi + .fn() + .mockRejectedValueOnce(new Error('disk unavailable')) + .mockResolvedValueOnce({ id: 'report-2' }) + const dedupe = new ProcessGoneDedupe() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + recordProcessGoneCrash({ record } as never, event(), dedupe) + await vi.waitFor(() => + expect(getCrashBreadcrumbSnapshot()).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })]) + ) + ) + recordProcessGoneCrash({ record } as never, event(), dedupe) + + await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts new file mode 100644 index 00000000000..daec429ae3e --- /dev/null +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -0,0 +1,137 @@ +import os from 'node:os' +import { app } from 'electron' +import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting' +import type { CrashReportStore } from './crash-report-store' +import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb' +import { + shouldRecordProcessGoneCrash, + type ExpectedTeardownScope, + type ProcessGoneSource +} from './process-gone-classification' +import { + buildProcessGoneCrashDetails, + buildSuppressedProcessGoneBreadcrumbData +} from './process-gone-diagnostics' +import { + getProcessGoneDedupeKey, + processGoneDedupe, + type ProcessGoneDedupe +} from './process-gone-dedupe' +import { flushActiveSink, startSpan } from '../observability/tracer' + +export type ProcessGoneCrashEvent = { + source: ProcessGoneSource + processType: string + reason: string + exitCode: number | null + expectedTeardown: ExpectedTeardownScope + details: Record +} + +type CrashReportRecorderStore = Pick + +function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { + return buildSuppressedProcessGoneBreadcrumbData(event) +} + +function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined + return { + ...processGoneBreadcrumbData(event), + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)), + ...(errorCode ? { errorCode } : {}) + } +} + +export function recordProcessGoneCrash( + store: CrashReportRecorderStore | null, + event: ProcessGoneCrashEvent, + dedupe: ProcessGoneDedupe = processGoneDedupe +): void { + if (!isCrashReportReason(event.reason)) { + return + } + if ( + !shouldRecordProcessGoneCrash({ + source: event.source, + processType: event.processType, + serviceName: + typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined, + reason: event.reason, + exitCode: event.exitCode, + expectedTeardown: event.expectedTeardown + }) + ) { + recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event)) + return + } + if (!store) { + recordDurableCrashBreadcrumb( + 'crash_report_store_unavailable', + processGoneBreadcrumbData(event), + 'Crash report store unavailable' + ) + return + } + + const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode) + const claim = dedupe.tryClaim(key) + if (!claim) { + return + } + const crashDetails = buildProcessGoneCrashDetails(event.details) + const breadcrumbs = getCrashBreadcrumbSnapshot() + const span = startSpan('electron.process_gone', { + attributes: { + 'crash.source': event.source, + 'crash.process_type': event.processType, + 'crash.reason': event.reason, + ...(event.exitCode !== null ? { 'crash.exit_code': event.exitCode } : {}), + 'app.version': app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + details: crashDetails, + breadcrumbs + } + }) + // Why: a renderer crash can be followed by another process exit before the + // trace batch window closes, so make the primary signal durable immediately. + span.fail( + `${event.source} process gone: ${event.processType} ${event.reason} (${event.exitCode ?? 'unknown'})` + ) + flushActiveSink() + + void store + .record({ + source: event.source, + processType: event.processType, + reason: event.reason, + exitCode: event.exitCode, + appVersion: app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron ?? 'unknown', + chromeVersion: process.versions.chrome ?? 'unknown', + details: crashDetails, + breadcrumbs + }) + .catch((error) => { + dedupe.release(claim) + console.error('[crash-reporting] Failed to persist crash report:', error) + const data = persistFailureData(event, error) + recordDurableCrashBreadcrumb( + 'crash_report_persist_failed', + data, + `${String(data.errorName)}: ${String(data.errorMessage)}` + ) + }) +} diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index 99d19818a3c..8077add319a 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -1,6 +1,5 @@ // Error classes shared across the daemon protocol boundary (client, server, // host). Split from types.ts, which is capped for wire-shape declarations. - export class TerminalAttachCanceledError extends Error { constructor(sessionId: string) { super(`Attach canceled for session ${sessionId}`) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts new file mode 100644 index 00000000000..4fbb3047d6a --- /dev/null +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +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) + }) +}) diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts new file mode 100644 index 00000000000..01b050c58d3 --- /dev/null +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -0,0 +1,11 @@ +export type GetForegroundProcessRequest = { + id: string + type: 'getForegroundProcess' + payload: { + sessionId: string + } +} + +export type ConfirmForegroundProcessRequest = Omit & { + type: 'confirmForegroundProcess' +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 310ea2aa955..06a77789f4b 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -489,9 +489,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('spawn with sessionId (reattach)', () => { it('returns full snapshot and isReattach when reattaching', async () => { const sessionId = 'reattach-test-session' - const first = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const first = await adapter.spawn({ cols: 80, rows: 24, sessionId, launchAgent: 'droid' }) expect(first.id).toBe(sessionId) expect(first.isReattach).toBeUndefined() + expect(first.launchAgent).toBe('droid') // Write data so the headless emulator captures it lastSubprocess._simulateData('hello from shell\r\n') @@ -501,6 +502,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const second = await adapter.spawn({ cols: 80, rows: 24, sessionId }) expect(second.id).toBe(sessionId) expect(second.isReattach).toBe(true) + expect(second.launchAgent).toBe('droid') expect(second.snapshot).toBeDefined() expect(second.snapshot).toContain('hello from shell') }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 3535b410404..e0d5ec598ca 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -237,6 +237,7 @@ export class DaemonPtyAdapter implements IPtyProvider { envToDelete: opts.envToDelete, command: opts.command, startupCommandDelivery: opts.startupCommandDelivery, + launchAgent: opts.launchAgent, // 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 @@ -252,6 +253,8 @@ export class DaemonPtyAdapter implements IPtyProvider { let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + const launchIdentity = (): { launchAgent?: NonNullable } => + result.launchAgent ? { launchAgent: result.launchAgent } : {} if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -278,6 +281,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), coldRestore: cachedRestore, ...(!result.isNew ? { isReattach: true } : {}) } @@ -342,11 +346,12 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), coldRestore, ...(!result.isNew ? { isReattach: true } : {}) } } - return { id: sessionId, pid } + return { id: sessionId, pid, ...launchIdentity() } } if (this.historyManager && result.isNew) { @@ -380,7 +385,12 @@ export class DaemonPtyAdapter implements IPtyProvider { const isReattach = !result.isNew if (!isReattach || !result.snapshot) { - return { id: sessionId, pid, ...(isReattach ? { isReattach: true } : {}) } + return { + id: sessionId, + pid, + ...launchIdentity(), + ...(isReattach ? { isReattach: true } : {}) + } } const isAltScreen = result.snapshot.modes.alternateScreen @@ -396,6 +406,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, @@ -658,6 +669,18 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + async confirmForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'confirmForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index a8c2b2232d8..eb8acb86488 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -63,6 +63,7 @@ function createAdapter( acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), getDefaultShell: vi.fn(async () => '/bin/zsh'), @@ -121,6 +122,22 @@ function createAdapter( } describe('DaemonPtyRouter', () => { + it('routes fresh foreground confirmation to the session-owning daemon', async () => { + const current = createAdapter('current', ['current-session']) + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await expect(router.confirmForegroundProcess('legacy-session')).resolves.toBe( + 'legacy-confirmed' + ) + await expect(router.confirmForegroundProcess('current-session')).resolves.toBe( + 'current-confirmed' + ) + expect(legacy.confirmForegroundProcess).toHaveBeenCalledWith('legacy-session') + expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') + }) + it('routes existing legacy sessions to their old daemon and new sessions to current daemon', async () => { const current = createAdapter('current') 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 ed43c663325..18bf0f926b5 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -146,6 +146,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).getForegroundProcess(id) } + async confirmForegroundProcess(id: string): Promise { + return this.adapterFor(id).confirmForegroundProcess(id) + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 7de928d7543..3760ee89de2 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -10,6 +10,8 @@ import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' import { getDaemonSocketPath } from './daemon-spawner' +const confirmForegroundProcessMock = vi.fn(async () => 'droid') + function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-server-test-')) } @@ -23,6 +25,7 @@ function createMockSubprocess(): SubprocessHandle & { return { pid: 55555, getForegroundProcess: vi.fn(() => null), + confirmForegroundProcess: confirmForegroundProcessMock, write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), @@ -65,6 +68,7 @@ describe('DaemonServer', () => { let client: DaemonClient beforeEach(() => { + confirmForegroundProcessMock.mockClear() dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -174,6 +178,34 @@ describe('DaemonServer', () => { }) }) + it('persists only an allowlisted launch identity across reattach', async () => { + await startServer() + const c = await connectClient() + + const first = await c.request('createOrAttach', { + sessionId: 'agent-session', + cols: 80, + rows: 24, + launchAgent: 'droid' + }) + expect(first).toMatchObject({ isNew: true, launchAgent: 'droid' }) + + const second = await c.request('createOrAttach', { + sessionId: 'agent-session', + cols: 80, + rows: 24 + }) + expect(second).toMatchObject({ isNew: false, launchAgent: 'droid' }) + + const unknown = await c.request('createOrAttach', { + sessionId: 'unknown-agent-session', + cols: 80, + rows: 24, + launchAgent: 'not-an-agent' + } as never) + expect(unknown).not.toHaveProperty('launchAgent') + }) + it('handles listSessions', async () => { await startServer() const c = await connectClient() @@ -279,6 +311,19 @@ describe('DaemonServer', () => { expect(result.cwd === null || typeof result.cwd === 'string').toBe(true) }) + it('awaits fresh foreground confirmation', async () => { + await startServer() + const c = await connectClient() + await c.request('createOrAttach', { sessionId: 'test-session', cols: 80, rows: 24 }) + + await expect( + c.request<{ foregroundProcess: string | null }>('confirmForegroundProcess', { + sessionId: 'test-session' + }) + ).resolves.toEqual({ foregroundProcess: 'droid' }) + expect(confirmForegroundProcessMock).toHaveBeenCalledTimes(1) + }) + it('returns error for unknown session operations', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7c604341c2c..fe87022e5c5 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,6 +22,7 @@ import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-syst import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' +import { isTuiAgent } from '../../shared/tui-agent-config' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -341,6 +342,9 @@ export class DaemonServer { envToDelete: p.envToDelete, command: p.command, startupCommandDelivery: p.startupCommandDelivery, + // 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 } : {}), shellOverride: p.shellOverride, terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, @@ -406,6 +410,7 @@ export class DaemonServer { snapshot: result.snapshot, pid: result.pid, shellState: result.shellState, + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } @@ -512,6 +517,11 @@ export class DaemonServer { case 'getForegroundProcess': return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + case 'confirmForegroundProcess': + return { + foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) + } + case 'clearScrollback': this.host.clearScrollback(request.payload.sessionId) return {} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index fb0b5087f15..188173816db 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -37,6 +37,7 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), @@ -108,6 +109,19 @@ function createDaemonAdapter( } describe('DegradedDaemonPtyProvider', () => { + it('routes fresh foreground confirmation to the session owner', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.confirmForegroundProcess('daemon-session')).resolves.toBe( + 'daemon-confirmed' + ) + await expect(provider.confirmForegroundProcess(fresh.id)).resolves.toBe('fallback-confirmed') + }) + it('routes discovered daemon sessions to the daemon and fresh PTYs to the fallback', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 4e818c9b6c0..572cb0aca37 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -160,6 +160,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.providerFor(id).getForegroundProcess(id) } + async confirmForegroundProcess(id: string): Promise { + return this.providerFor(id).confirmForegroundProcess?.(id) ?? null + } + async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) } diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index b198f3e7475..937dca27bc4 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -40,7 +40,10 @@ vi.mock('../providers/windows-powershell-executable', () => ({ })) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ + available: true, + processName: await resolveAgentForegroundProcessMock(...args) + }) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9ff69f89ab3..99a35047743 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,12 +53,18 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveAgentForegroundProcessMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } })) import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' @@ -111,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] @@ -176,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) @@ -514,6 +635,111 @@ describe('createPtySubprocess', () => { } }) + it('preserves the ordinary fallback when Windows process enumeration is unavailable', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: false, + processName: 'powershell.exe' + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + + expect(handle.getForegroundProcess()).toBe('powershell.exe') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(handle.getForegroundProcess()).toBe('powershell.exe') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('awaits a fresh delayed Windows scan instead of serving the shell fallback', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + let resolveFresh!: (processName: string) => void + resolveAgentForegroundProcessMock.mockReturnValue( + new Promise((resolve) => { + resolveFresh = resolve + }) + ) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + const confirmation = handle.confirmForegroundProcess!() + let settled = false + void confirmation.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveFresh('droid') + await expect(confirmation).resolves.toBe('droid') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledExactlyOnceWith( + proc.pid, + 'powershell.exe', + expect.objectContaining({ fresh: true, forceProcessScan: true }) + ) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('returns null when a fresh Windows confirmation scan is unavailable', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: false, + processName: 'powershell.exe' + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + await expect(handle.confirmForegroundProcess!()).resolves.toBeNull() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('returns null when a recognized Windows fallback disappears during confirmation', async () => { + const proc = mockPtyProcess() + proc.process = 'droid' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: null + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + await expect(handle.confirmForegroundProcess!()).resolves.toBeNull() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('serves Unix agent foreground when node-pty reports an agent child process', async () => { const proc = mockPtyProcess() proc.process = 'uv' @@ -2181,6 +2407,7 @@ describe('createPtySubprocess', () => { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', env: { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_HERMES_STARTUP_QUERY: 'line one\nline two', WSLENV: 'FOO/u' } }) @@ -2207,7 +2434,12 @@ describe('createPtySubprocess', () => { // Why: the daemon inherits optional agent-hook env in development. This // test owns only the terminal handle and Powerlevel10k WSLENV contract. expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( - expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV]) + expect.arrayContaining([ + 'FOO/u', + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 119b3fb9430..ce1b1faf35c 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' @@ -41,7 +42,8 @@ import { } from '../pty/powerlevel10k-wizard-env' import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process' +import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process' +import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership' import { isAgentForegroundWrapperProcess, recognizeAgentProcess, @@ -55,6 +57,7 @@ import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -716,6 +719,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore @@ -735,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 @@ -940,10 +959,10 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: daemon foreground reads are sync and run on the IPC hot path. // Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.) // in the background and serve them from a short cache on later reads. - void resolveAgentForegroundProcess(proc.pid, fallbackProcess, { + void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { contextPaths: agentForegroundContextPaths }) - .then((processName) => { + .then(({ processName }) => { if (dead) { return } @@ -1000,6 +1019,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 @@ -1045,6 +1065,56 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return null } }, + confirmForegroundProcess: async () => { + if (dead || !proc.pid) { + return null + } + try { + const fallbackProcess = getFallbackForegroundProcess() + if ( + !fallbackProcess || + (recognizeAgentProcess(fallbackProcess) && process.platform !== 'win32') || + (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) + ) { + return fallbackProcess + } + // Why: cached/in-flight scans may predate the OSC command boundary. + // Confirmation requires one shared process snapshot started afterward. + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths: agentForegroundContextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + if (dead || !resolution.available) { + return null + } + const recognized = recognizeAgentProcess(resolution.processName) + if (recognized) { + cachedAgentForeground = { + processName: recognized.processName, + refreshedAt: Date.now() + } + startupAgentForeground = null + return recognized.processName + } + // Why: a successful post-boundary scan that resolves no agent is the + // authority that retires stale cached/startup identity. + cachedAgentForeground = null + startupAgentForeground = null + return resolution.processName + } catch { + return null + } + }, write: (data) => { if (dead) { return diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 80987b5a1bb..b9c82a48174 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -9,6 +9,7 @@ import { type ShellReadyScanState } from '../shell-ready-marker-scanner' import { isPowerShellProcess } from '../../shared/shell-process-detection' +import type { TuiAgent } from '../../shared/types' import type { PendingOutputRecord, SessionState, @@ -42,9 +43,15 @@ export type SubprocessHandle = { /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(): string | null + /** Await process-table evidence captured after this confirmation request. */ + confirmForegroundProcess?(): Promise /** 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 @@ -72,6 +79,7 @@ export type SessionOptions = { cols: number rows: number terminalHandle?: string + launchAgent?: TuiAgent subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -94,6 +102,7 @@ type AttachedClient = { export class Session { readonly sessionId: string readonly terminalHandle: string | null + readonly launchAgent: TuiAgent | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -120,6 +129,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null + this.launchAgent = opts.launchAgent ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) @@ -360,6 +370,10 @@ export class Session { return this.subprocess.getForegroundProcess() } + async confirmForegroundProcess(): Promise { + return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() + } + clearScrollback(): void { if (this._disposed) { return 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/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts new file mode 100644 index 00000000000..a60570ac5fe --- /dev/null +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -0,0 +1,33 @@ +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/types' +import type { ShellReadyState, TerminalSnapshot } from './types' + +export type CreateOrAttachOptions = { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + command?: string + startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent + /** Explicit shell the renderer asked for, forwarded to the subprocess. */ + shellOverride?: string + terminalWindowsWslDistro?: string | null + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + shellReadySupported?: boolean + shellReadyTimeoutMs?: number + historySeed?: string + streamClient: { onData: (data: string) => void; onExit: (code: number) => void } +} + +export type CreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState + historySeeded?: boolean + launchAgent?: TuiAgent + attachToken: symbol +} diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f92d4c1e1f..46e495e5f2e 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(), @@ -193,6 +194,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({ diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 644356a6c4f..b15420fed14 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,49 +1,17 @@ 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, - ShellReadyState -} from './types' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import { SessionNotFoundError } from './types' +import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' + +export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' const DEFAULT_MAX_TOMBSTONES = 1000 -export type CreateOrAttachOptions = { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - command?: string - startupCommandDelivery?: StartupCommandDelivery - /** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL - * terminal" from the "+" menu). Forwarded to the subprocess spawner so the - * daemon path honors per-tab shell selection the same way LocalPtyProvider - * does. */ - shellOverride?: string - terminalWindowsWslDistro?: string | null - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - shellReadySupported?: boolean - shellReadyTimeoutMs?: number - historySeed?: string - streamClient: { onData: (data: string) => void; onExit: (code: number) => void } -} - -export type CreateOrAttachResult = { - isNew: boolean - snapshot: TerminalSnapshot | null - pid: number | null - shellState: ShellReadyState - historySeeded?: boolean - attachToken: symbol -} - export type TerminalHostOptions = { spawnSubprocess: (opts: { sessionId: string @@ -107,6 +75,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, + ...(existing.launchAgent ? { launchAgent: existing.launchAgent } : {}), ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), attachToken: token } @@ -136,13 +105,24 @@ 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, cols: size.cols, rows: size.rows, terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + launchAgent: opts.launchAgent, subprocess, - shellReadySupported: opts.shellReadySupported ?? false, + shellReadySupported, historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's @@ -174,7 +154,7 @@ export class TerminalHost { session.write( buildStartupCommandSubmission(opts.command, { submit, - bracketedPasteSafe: opts.shellReadySupported ?? false + bracketedPasteSafe: shellReadySupported }) ) } @@ -184,6 +164,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, + ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), attachToken: token } @@ -274,6 +255,14 @@ export class TerminalHost { return session.getForegroundProcess() } + async confirmForegroundProcess(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + return session.confirmForegroundProcess() + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 663166e6fca..550b211aa74 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,15 +1,24 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { + ConfirmForegroundProcessRequest, + GetForegroundProcessRequest +} from './daemon-foreground-process-protocol' + +export type { + ConfirmForegroundProcessRequest, + GetForegroundProcessRequest +} from './daemon-foreground-process-protocol' // ─── 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 = 20 +export const PROTOCOL_VERSION = 21 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 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -98,6 +107,7 @@ export type CreateOrAttachRequest = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors * the shell picked in the "+" menu or the persisted default-shell setting, @@ -216,14 +226,6 @@ export type GetCwdRequest = { } } -export type GetForegroundProcessRequest = { - id: string - type: 'getForegroundProcess' - payload: { - sessionId: string - } -} - export type ClearScrollbackRequest = { id: string type: 'clearScrollback' @@ -330,6 +332,7 @@ export type DaemonRequest = | DetachRequest | GetCwdRequest | GetForegroundProcessRequest + | ConfirmForegroundProcessRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest @@ -361,8 +364,8 @@ export type CreateOrAttachResult = { pid: number | null shellState: ShellReadyState historySeeded?: boolean + launchAgent?: TuiAgent } - export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index bf9d7a61fa1..69b57d60b3b 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -78,6 +78,54 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + 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 + const originalSet = buffers.set + let setCalls = 0 + buffers.set = function (key, value): Map { + setCalls += 1 + return originalSet.call(this, key, value) + } + watcher.bindPty('pty-1', 'worktree-1') + + try { + for (let index = 0; index < 4096; index += 1) { + watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n') + } + } finally { + buffers.set = originalSet + } + + expect(setCalls).toBe(0) + expect(buffers.has('pty-1')).toBe(false) + watcher.stop() + }) + + it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => { + const watcher = createIsolatedWatcher() + const events: ServeSimStateDetectedEvent[] = [] + const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers + const payload = JSON.stringify({ + device: TEST_UDID, + streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3100/ws' + }) + const splitIndex = payload.indexOf('streamUrl') + 4 + watcher.bindPty('pty-1', 'worktree-1') + watcher.onDetected((event) => events.push(event)) + + watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex)) + expect(events).toHaveLength(0) + expect(buffers.has('pty-1')).toBe(true) + + watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex)) + expect(events).toHaveLength(1) + expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) + expect(buffers.has('pty-1')).toBe(false) + watcher.stop() + }) + it('suppresses Orca-managed sessions only while they are marked managed', async () => { const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index f250fe7f7b9..1a42b2d0353 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -66,6 +66,11 @@ function helperInstanceKey(info: ServeSimHelperInfo): string { return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}` } +function trailingIncompletePtyJsonObject(data: string): string { + const lastObjectStart = data.lastIndexOf('{') + return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : '' +} + export class ServeSimStateWatcher { private readonly stateDir: string private readonly ptyToWorktree = new Map() @@ -132,9 +137,19 @@ export class ServeSimStateWatcher { return } - const prev = this.ptyBuffers.get(ptyId) ?? '' - const combined = (prev + data).slice(-16_384) - this.ptyBuffers.set(ptyId, combined) + const previous = this.ptyBuffers.get(ptyId) + if (!previous && !data.includes('{')) { + // Why: serve-sim metadata is a JSON object, while ordinary PTY output is + // the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB. + return + } + const combined = `${previous ?? ''}${data}`.slice(-16_384) + const trailingObject = trailingIncompletePtyJsonObject(combined) + if (trailingObject) { + this.ptyBuffers.set(ptyId, trailingObject) + } else { + this.ptyBuffers.delete(ptyId) + } const matches = combined.match(PTY_JSON_RE) if (!matches) { diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index 5e8152af86a..a7b610f93cd 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -1,6 +1,7 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' /** * Reject branch names git would parse as an option (`-`/`--…`) or that aren't a @@ -27,7 +28,9 @@ export async function checkoutBranch( options: GitRuntimeOptions = {} ): Promise { assertValidBranchName(branch) - await gitExecFileAsync(['checkout', branch, '--'], gitOptionsForWorktree(worktreePath, options)) + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['checkout', branch, '--'], gitOptionsForWorktree(worktreePath, options)) + ) } /** diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index d2b1c0b5dc4..ab7fba775f9 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -7,6 +7,7 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' async function getConfiguredPushTarget( worktreePath: string, @@ -249,7 +250,9 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await gitPullWithArgs(worktreePath, [], pushTarget, options) + await runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, [], pushTarget, options) + ) } export async function gitFastForward( @@ -257,7 +260,9 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + await runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) } export async function gitPullRebaseFromBase( @@ -265,18 +270,20 @@ export async function gitPullRebaseFromBase( baseRef: string, options: GitRuntimeOptions = {} ): Promise { - try { - const source = await resolveGitRemoteRebaseSource( - (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), - baseRef - ) - await gitExecFileAsync( - ['pull', '--rebase', source.remoteName, source.branchName], - gitOptionsForWorktree(worktreePath, options) - ) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } + await runWithGitReadCacheInvalidation(async () => { + try { + const source = await resolveGitRemoteRebaseSource( + (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), + baseRef + ) + await gitExecFileAsync( + ['pull', '--rebase', source.remoteName, source.branchName], + gitOptionsForWorktree(worktreePath, options) + ) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } + }) } export async function gitFetch( diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 5c3a0769366..4330a3be9e5 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -24,7 +24,8 @@ vi.mock('./runner', () => ({ })) vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock + resolveGitDir: resolveGitDirMock, + runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) vi.mock('fs/promises', async () => { diff --git a/src/main/git/repo-default-base-timeout.test.ts b/src/main/git/repo-default-base-timeout.test.ts new file mode 100644 index 00000000000..b7c1518bc77 --- /dev/null +++ b/src/main/git/repo-default-base-timeout.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getBaseRefDefault } from './repo' + +describe('getBaseRefDefault async subprocess bounds', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('bounds every local probe and degrades a timeout to no default', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect(getBaseRefDefault('/repo')).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ cwd: '/repo', timeout: 15_000 }) + } + }) + + it('preserves the same timeout when routing probes through WSL', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect( + getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ + cwd: '\\\\wsl.localhost\\Ubuntu\\repo', + timeout: 15_000, + wslDistro: 'Ubuntu' + }) + } + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index f649017cdeb..fa1c23ebf0d 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -15,6 +15,13 @@ type LocalGitExecOptions = { wslDistro?: string } +type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } @@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise hasGitRefViaExec(exec, ref)) } +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + async function getDefaultBaseRefAsync( path: string, options: LocalGitExecOptions = {} ): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, gitExecOptions(path, options)) - ) + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) } /** diff --git a/src/main/git/status-submodule-path-cache.test.ts b/src/main/git/status-submodule-path-cache.test.ts new file mode 100644 index 00000000000..60adccf142c --- /dev/null +++ b/src/main/git/status-submodule-path-cache.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, gitStreamStdoutMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileAsyncBufferMock: vi.fn(), + gitStreamStdoutMock: vi.fn() + }) +) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock, + gitStreamStdout: gitStreamStdoutMock, + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ + ...env, + GIT_OPTIONAL_LOCKS: '0' + }) +})) + +import { + MAX_SUBMODULE_PATHS_CACHE_ENTRIES, + abortMerge, + abortRebase, + clearSubmodulePathsCacheForTests, + getSubmodulePathsCacheCountForTests, + listSubmodulePaths +} from './status' +import { checkoutBranch } from './checkout' +import { gitPull, gitPullRebaseFromBase } from './remote' +import { addWorktree, removeWorktree } from './worktree' + +describe('submodule path cache', () => { + beforeEach(() => { + clearSubmodulePathsCacheForTests() + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncBufferMock.mockReset() + gitStreamStdoutMock.mockReset() + gitExecFileAsyncMock.mockImplementation((_args: string[], options?: { cwd?: string }) => + Promise.resolve({ + stdout: `submodule.lib.path ${String(options?.cwd ?? 'repo').replace(/^.*[/\\\\]/, '')}-lib\n` + }) + ) + }) + + afterEach(() => { + vi.useRealTimers() + clearSubmodulePathsCacheForTests() + }) + + it('prunes expired entries even when later reads use different worktrees', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + await listSubmodulePaths('/repo-a') + await listSubmodulePaths('/repo-b') + + expect(getSubmodulePathsCacheCountForTests()).toBe(2) + + vi.setSystemTime(5_001) + await expect(listSubmodulePaths('/repo-c')).resolves.toEqual(['repo-c-lib']) + + expect(getSubmodulePathsCacheCountForTests()).toBe(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('stays bounded through prolonged worktree churn and keeps recently reused worktrees', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + for (let wave = 0; wave < 4; wave += 1) { + for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES; i += 1) { + await listSubmodulePaths(`/wave-${wave}-repo-${i}`) + } + expect(getSubmodulePathsCacheCountForTests()).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) + vi.advanceTimersByTime(5_001) + } + + await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib']) + for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1; i += 1) { + await listSubmodulePaths(`/final-repo-${i}`) + } + await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib']) + await listSubmodulePaths(`/final-repo-${MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1}`) + await listSubmodulePaths('/overflow-repo') + + expect(getSubmodulePathsCacheCountForTests()).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) + + const callsBeforeRetainedRead = gitExecFileAsyncMock.mock.calls.length + await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib']) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(callsBeforeRetainedRead) + + await expect(listSubmodulePaths('/final-repo-0')).resolves.toEqual(['final-repo-0-lib']) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(callsBeforeRetainedRead + 1) + }) + + it('does not let a pre-invalidation read repopulate the cache', async () => { + let resolveOldRead: ((value: { stdout: string }) => void) | undefined + gitExecFileAsyncMock.mockImplementationOnce( + () => + new Promise<{ stdout: string }>((resolve) => { + resolveOldRead = resolve + }) + ) + + const oldRead = listSubmodulePaths('/repo') + expect(resolveOldRead).toBeTypeOf('function') + clearSubmodulePathsCacheForTests() + resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n' }) + + await expect(oldRead).resolves.toEqual(['old-lib']) + expect(getSubmodulePathsCacheCountForTests()).toBe(0) + await expect(listSubmodulePaths('/repo')).resolves.toEqual(['repo-lib']) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + 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') { + modulePath = 'feature-lib' + return Promise.resolve({ stdout: '' }) + } + return Promise.resolve({ stdout: `submodule.lib.path ${modulePath}\n` }) + }) + const runtime = { wslDistro: 'Ubuntu' } + + await expect(listSubmodulePaths('/repo', runtime)).resolves.toEqual(['main-lib']) + await checkoutBranch('/repo', 'feature', runtime) + await expect(listSubmodulePaths('/repo', runtime)).resolves.toEqual(['feature-lib']) + + const configReads = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(configReads).toHaveLength(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['checkout', 'feature', '--'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + + it.each([ + ['pull', () => gitPull('/repo')], + ['rebase', () => gitPullRebaseFromBase('/repo', 'origin/main')] + ])('invalidates submodule paths around local %s', async (_name, mutate) => { + let modulePath = 'old-lib' + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\n' }) + } + if (args[0] === 'pull') { + modulePath = 'fresh-lib' + return Promise.resolve({ stdout: '' }) + } + if (args[0] === 'config' && args.includes('.gitmodules')) { + return Promise.resolve({ stdout: `submodule.lib.path ${modulePath}\n` }) + } + return Promise.resolve({ stdout: '' }) + }) + + await expect(listSubmodulePaths('/repo')).resolves.toEqual(['old-lib']) + await mutate() + await expect(listSubmodulePaths('/repo')).resolves.toEqual(['fresh-lib']) + + const configReads = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(configReads).toHaveLength(2) + }) + + it.each([ + ['merge', () => abortMerge('/repo', { wslDistro: 'Ubuntu' })], + ['rebase', () => abortRebase('/repo', { wslDistro: 'Ubuntu' })] + ])('drops a cached empty .gitmodules result when %s abort restores it', async (_name, abort) => { + let modulePath = '' + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[1] === '--abort') { + modulePath = 'restored-lib' + return Promise.resolve({ stdout: '' }) + } + return Promise.resolve({ + stdout: modulePath ? `submodule.lib.path ${modulePath}\n` : '' + }) + }) + + await expect(listSubmodulePaths('/repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([]) + await abort() + await expect(listSubmodulePaths('/repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([ + 'restored-lib' + ]) + + const configReads = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(configReads).toHaveLength(2) + }) + + it('drops a same-path negative cache when a local or WSL worktree is recreated', async () => { + let recreated = false + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'add') { + recreated = true + } + if (args[0] === 'config' && args.includes('.gitmodules')) { + return Promise.resolve({ + stdout: recreated ? 'submodule.lib.path recreated-lib\n' : '' + }) + } + return Promise.resolve({ stdout: '' }) + }) + const runtime = { wslDistro: 'Ubuntu' } + + await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([]) + await removeWorktree('/repo', '/repo-feature', true, { + ...runtime, + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) + await addWorktree('/repo', '/repo-feature', 'feature', undefined, false, false, { + ...runtime, + checkoutExistingBranch: true + }) + await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual(['recreated-lib']) + + const configReads = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(configReads).toHaveLength(2) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 6ec73ef8a3b..ddbdd1b2ca4 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -64,8 +64,10 @@ type EffectiveUpstreamStatusCacheEntry = { } const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 +export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() +let submodulePathsCacheGeneration = 0 // Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3 // + config snapshot) costs 4-5 subprocess spawns and only changes when branch @@ -92,15 +94,37 @@ const statusReadsInFlight = new Map>() // Why: a mutation invalidates both in-flight diff reads and in-flight status // coalescing; clearing only the diff dedupe would let a post-mutation // getStatus() join a pre-mutation read and return stale entries. -function clearGitReadInvalidationState(): void { +export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() - submodulePathsCache.clear() + clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() } +export async function runWithGitReadCacheInvalidation(run: () => Promise): Promise { + invalidateGitReadCaches() + try { + return await run() + } finally { + // Why: a read that started during the mutation can be stale too, so the + // post-mutation boundary retires both pre-existing and overlapping reads. + invalidateGitReadCaches() + } +} + export function clearSubmodulePathsCacheForTests(): void { + clearSubmodulePathsCache() +} + +function clearSubmodulePathsCache(): void { submodulePathsCache.clear() + // Why: a pre-mutation .gitmodules read must not repopulate the cache after + // the mutation invalidated it. + submodulePathsCacheGeneration += 1 +} + +export function getSubmodulePathsCacheCountForTests(): number { + return submodulePathsCache.size } function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { @@ -113,6 +137,44 @@ function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOpti return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0') } +function pruneExpiredSubmodulePathsCache(now: number): void { + for (const [cacheKey, entry] of submodulePathsCache) { + if (entry.expiresAt <= now) { + submodulePathsCache.delete(cacheKey) + } + } +} + +function trimSubmodulePathsCache(): void { + while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { + const oldestKey = submodulePathsCache.keys().next().value + if (oldestKey === undefined) { + break + } + submodulePathsCache.delete(oldestKey) + } +} + +function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null { + const cached = submodulePathsCache.get(cacheKey) + if (!cached) { + return null + } + if (cached.expiresAt <= now) { + submodulePathsCache.delete(cacheKey) + return null + } + submodulePathsCache.delete(cacheKey) + submodulePathsCache.set(cacheKey, cached) + return cached.paths +} + +function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { + submodulePathsCache.delete(cacheKey) + submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + trimSubmodulePathsCache() +} + // Why: status tests reuse this reset hook, so every cross-call memoization layer // must reset together even though the historical name mentions upstream only. export function clearEffectiveUpstreamStatusCacheForTests(): void { @@ -120,7 +182,7 @@ export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusInFlight.clear() retiredEffectiveUpstreamStatusInFlight.clear() effectiveUpstreamStatusWriteGeneration.clear() - clearGitReadInvalidationState() + invalidateGitReadCaches() } export function getEffectiveUpstreamStatusCacheCountForTests(): number { @@ -908,14 +970,18 @@ export async function abortMerge( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { - await gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) + ) } export async function abortRebase( worktreePath: string, options: GitRuntimeOptions = {} ): Promise { - await gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) + ) } export async function resolveGitDir(worktreePath: string): Promise { @@ -945,10 +1011,14 @@ export async function listSubmodulePaths( ): Promise { const now = Date.now() const cacheKey = getSubmodulePathsCacheKey(worktreePath, options) - const cached = submodulePathsCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.paths + const cached = getCachedSubmodulePaths(cacheKey, now) + if (cached) { + return cached } + // Why: prune on misses so removed worktrees do not accumulate while hot + // cache hits stay O(1). + pruneExpiredSubmodulePathsCache(now) + const cacheGeneration = submodulePathsCacheGeneration let paths: string[] = [] try { const { stdout } = await gitExecFileAsync( @@ -971,7 +1041,9 @@ export async function listSubmodulePaths( // No .gitmodules (or git config failure) — treat as a repo without submodules. paths = [] } - submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + if (cacheGeneration === submodulePathsCacheGeneration) { + rememberSubmodulePaths(cacheKey, paths, Date.now()) + } return paths } @@ -1834,14 +1906,14 @@ export async function stageFile( filePath: string, options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() try { await gitExecFileAsync( ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -1853,13 +1925,13 @@ export async function unstageFile( filePath: string, options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() try { await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -1916,7 +1988,7 @@ export async function commitChanges( message: string, options: GitRuntimeOptions = {} ): Promise<{ success: boolean; error?: string }> { - clearGitReadInvalidationState() + invalidateGitReadCaches() try { await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) return { success: true } @@ -1939,7 +2011,7 @@ export async function commitChanges( (error instanceof Error ? error.message : 'Commit failed') return { success: false, error: errorMessage } } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -1951,7 +2023,7 @@ export async function discardChanges( filePath: string, options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() const resolvedWorktree = path.resolve(worktreePath) const resolvedTarget = path.resolve(worktreePath, filePath) try { @@ -1986,7 +2058,7 @@ export async function discardChanges( cleanUntrackedPaths(worktreePath, [targetPath], options) ) } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -2061,7 +2133,7 @@ export async function bulkDiscardChanges( filePaths: string[], options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() if (filePaths.length === 0) { return } @@ -2105,7 +2177,7 @@ export async function bulkDiscardChanges( } ) } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -2131,7 +2203,7 @@ export async function bulkStageFiles( filePaths: string[], options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() if (filePaths.length === 0) { return } @@ -2144,7 +2216,7 @@ export async function bulkStageFiles( ) } } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } @@ -2156,7 +2228,7 @@ export async function bulkUnstageFiles( filePaths: string[], options: GitRuntimeOptions = {} ): Promise { - clearGitReadInvalidationState() + invalidateGitReadCaches() if (filePaths.length === 0) { return } @@ -2176,6 +2248,6 @@ export async function bulkUnstageFiles( ) } } finally { - clearGitReadInvalidationState() + invalidateGitReadCaches() } } diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 858b7c2bfd6..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 }) @@ -1254,6 +1382,43 @@ describe('addWorktree', () => { ]) }) + it('skips advisory owner probes when the local base is already current', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // resolve creation base + .mockResolvedValueOnce({ stdout: '0\t0\n' }) // local base is current + .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockResolvedValueOnce({ stdout: '' }) // persist branch base + .mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + + await expect( + addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', false, false, { + suggestLocalBaseRefUpdate: true + }) + ).resolves.toEqual({}) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + [ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature/test', + '/repo-feature', + 'refs/remotes/origin/main' + ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + ['config', '--get', 'push.autoSetupRemote'] + ]) + }) + it('uses normalized branch metadata for slash-containing remotes', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 19b84a7b3ea..c9175b3dcb4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -24,7 +24,7 @@ import { } from '../../shared/git-worktree-command-capabilities' import { getLocalGitCapabilityCache } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' -import { resolveGitDir } from './status' +import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' export type AddWorktreeResult = { @@ -172,7 +172,8 @@ async function evaluateLocalBaseRefRefreshability( baseBranch: string, remoteTrackingRef: string, remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + shouldInspectOwner: (behind: number) => boolean = () => true ): Promise { const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) if (!parsed) { @@ -196,6 +197,11 @@ async function evaluateLocalBaseRefRefreshability( if (!parsedDrift || parsedDrift.ahead !== 0) { return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } + if (!shouldInspectOwner(parsedDrift.behind)) { + // Why: a current local ref cannot produce an update suggestion, so the + // advisory path need not resolve OIDs or inspect its owner worktree. + return undefined + } const { stdout: localOidOutput } = await gitExecFileAsync( ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], gitExecOptions(repoPath, options) @@ -289,7 +295,8 @@ async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( baseBranch, remoteTrackingRef, remoteTrackingBase, - options + options, + (behind) => behind > 0 ) if (!evaluation?.refreshable || evaluation.behind <= 0) { return undefined @@ -643,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, @@ -669,6 +714,7 @@ export function listWorktrees( if (inFlightWorktreeScans.get(key) === scan) { inFlightWorktreeScans.delete(key) } + pruneWorktreeScanGeneration(repoPath) }) inFlightWorktreeScans.set(key, scan) return scan @@ -831,14 +877,16 @@ export async function addWorktree( options: AddWorktreeOptions = {} ): Promise { try { - return await performAddWorktree( - repoPath, - worktreePath, - branch, - baseBranch, - refreshLocalBaseRef, - noCheckout, - options + return await runWithGitReadCacheInvalidation(() => + performAddWorktree( + repoPath, + worktreePath, + branch, + baseBranch, + refreshLocalBaseRef, + noCheckout, + options + ) ) } finally { bumpWorktreeScanGeneration(repoPath) @@ -1049,7 +1097,9 @@ export async function moveWorktree( newPath: string ): Promise { try { - await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath }) + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath }) + ) } finally { bumpWorktreeScanGeneration(repoPath) } @@ -1069,7 +1119,9 @@ export async function removeWorktree( options: RemoveWorktreeOptions = {} ): Promise { try { - return await performRemoveWorktree(repoPath, worktreePath, force, options) + return await runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) } finally { bumpWorktreeScanGeneration(repoPath) } 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..b4702f28128 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2392,6 +2392,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 +2412,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 +2548,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 +2561,7 @@ async function getTrackedUpstreamBranch( if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { trackedUpstreamSnapshotInFlight.delete(cacheKey) } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) } } diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index f68726b395a..0f65fc1572c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -239,6 +239,163 @@ describe('issue source operations', () => { ) }) + it('recovers issue 7704 oversized inline-image creation', async () => { + const imagePrefix = 'data:image/png;base64,' + const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length) + expect(body).toContain('data:image') + expect(body).toHaveLength(133596) + + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 422: body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 926, + html_url: 'https://github.com/stablyai/orca/issues/926' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue('/repo-root', 'Image issue', body, undefined, undefined, { + labels: ['bug'], + assignees: ['octo'] + }) + ).resolves.toEqual({ + ok: true, + number: 926, + url: 'https://github.com/stablyai/orca/issues/926' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + `body=${body}`, + 'title=Image issue', + 'labels[]=bug', + 'assignees[]=octo' + ]), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], + { cwd: '/repo-root' } + ) + }) + + it('recognizes the oversized-body response from structured gh stderr', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: gh'), { + stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 929, + html_url: 'https://github.com/stablyai/orca/issues/929' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 929, + url: 'https://github.com/stablyai/orca/issues/929' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('does not retry unrelated create failures', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid')) + + await expect(createIssue('/repo-root', 'Invalid issue', 'Body')).resolves.toEqual({ + ok: false, + error: 'HTTP 422: assignees is invalid' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('stops when placeholder create fails during oversized-body recovery', async () => { + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockRejectedValueOnce(new Error('HTTP 500: create failed')) + + await expect(createIssue('/repo-root', 'Placeholder failure', body)).resolves.toEqual({ + ok: false, + error: 'HTTP 500: create failed' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('preserves fields during oversized-body recovery', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue( + '/repo-root', + 'Fields issue', + body, + undefined, + null, + { + labels: ['bug'], + assignees: ['octo'] + }, + localGitOptions + ) + ).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' }) + const firstCreateArgs = [...ghExecFileAsyncMock.mock.calls[0][0]] + const fallbackCreateArgs = [...ghExecFileAsyncMock.mock.calls[1][0]] + firstCreateArgs[firstCreateArgs.indexOf(`body=${body}`)] = 'body=' + expect(fallbackCreateArgs).toEqual(firstCreateArgs) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + null, + localGitOptions + ) + }) + + it('reports partial success when oversized-body patch fails', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 928, + html_url: 'https://github.com/stablyai/orca/issues/928' + }) + }) + .mockRejectedValueOnce(new Error('HTTP 500: update failed')) + + await expect(createIssue('/repo-root', 'Partial issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 928, + url: 'https://github.com/stablyai/orca/issues/928', + bodySaveWarning: + 'Issue https://github.com/stablyai/orca/issues/928 was created, but saving its body failed: HTTP 500: update failed' + }) + }) + it('updates issue body through the REST issue endpoint', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 8f80fc6451f..b0f3fa6cb89 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -6,6 +6,7 @@ import type { ClassifiedError, GitHubAssignableUser, GitHubCreateIssueFields, + GitHubCreateIssueResult, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, @@ -15,7 +16,7 @@ import type { import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -31,6 +32,11 @@ export type IssueListResult = { error?: ClassifiedError } +function githubIssueErrorMessage(error: unknown): string { + const { stderr, stdout } = extractExecError(error) + return stderr.trim() || stdout.trim() +} + /** * Get a single issue by number. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. @@ -162,7 +168,7 @@ export async function createIssue( connectionId?: string | null, fields?: GitHubCreateIssueFields, localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { +): Promise { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } @@ -180,25 +186,71 @@ export async function createIssue( } await acquire() try { - const args = [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, - '--raw-field', - `title=${trimmedTitle}`, - '--raw-field', - `body=${body}` - ] - for (const label of fields?.labels ?? []) { - args.push('--raw-field', `labels[]=${label}`) + const createArgs = (issueBody: string) => { + const args = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, + '--raw-field', + `title=${trimmedTitle}`, + '--raw-field', + `body=${issueBody}` + ] + for (const label of fields?.labels ?? []) { + args.push('--raw-field', `labels[]=${label}`) + } + for (const assignee of fields?.assignees ?? []) { + args.push('--raw-field', `assignees[]=${assignee}`) + } + return args } - for (const assignee of fields?.assignees ?? []) { - args.push('--raw-field', `assignees[]=${assignee}`) + + const parseIssue = (stdout: string) => + JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } + + let data: { number?: number; html_url?: string; url?: string } + try { + const { stdout } = await ghExecFileAsync(createArgs(body), ghOptions) + data = parseIssue(stdout) + } catch (err) { + const message = githubIssueErrorMessage(err) + if (!/body is too long \(maximum is \d+ characters\)/i.test(message)) { + return { ok: false, error: message } + } + + // Why: GitHub rejects oversized bodies on create but accepts the same body + // on update, so establish the issue before attaching its body. + const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions) + data = parseIssue(stdout) + if (typeof data.number !== 'number') { + return { ok: false, error: 'Unexpected response from GitHub' } + } + + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${data.number}`, + '--raw-field', + `body=${body}` + ], + ghOptions + ) + } catch (patchErr) { + const patchMessage = githubIssueErrorMessage(patchErr) + const identity = data.html_url ?? data.url ?? `#${data.number}` + return { + ok: true, + number: data.number, + url: String(data.html_url ?? data.url ?? ''), + bodySaveWarning: `Issue ${identity} was created, but saving its body failed: ${patchMessage}` + } + } } - const { stdout } = await ghExecFileAsync(args, ghOptions) - const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } } @@ -208,8 +260,7 @@ export async function createIssue( url: String(data.html_url ?? data.url ?? '') } } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } + return { ok: false, error: githubIssueErrorMessage(err) } } finally { release() } diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 3e53d0116d1..5ee1a882a62 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -5,12 +5,14 @@ import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' const { sendMock, + sendToTrustedUIRendererMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock, rateLimitGuardMock } = vi.hoisted(() => ({ sendMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), getAllWebContentsMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), getRateLimitMock: vi.fn(), @@ -33,6 +35,10 @@ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock })) +vi.mock('../ipc/ui', () => ({ + sendToTrustedUIRenderer: sendToTrustedUIRendererMock +})) + function makeCandidate( overrides: Partial = {} ): GitHubPRRefreshCandidate { @@ -82,6 +88,10 @@ describe('pr-refresh-coordinator', () => { vi.useFakeTimers() vi.setSystemTime(1_000) sendMock.mockReset() + sendToTrustedUIRendererMock.mockReset() + sendToTrustedUIRendererMock.mockImplementation((channel, payload) => { + sendMock(channel, payload) + }) getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRateLimitMock.mockReset() @@ -101,6 +111,29 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + + enqueuePRRefresh(makeCandidate({ isBare: true }), 'manual') + + expect(sendToTrustedUIRendererMock).toHaveBeenCalledOnce() + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:prRefreshEvent', + expect.objectContaining({ status: 'skipped', skippedReason: 'bare' }) + ) + expect(sendMock).toHaveBeenCalledOnce() + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) + }) + it('forwards the candidate worktree head into the branch lookup options', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8065fbb8145..bd59fc77cf2 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -13,6 +13,7 @@ import type { import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' type QueueEntry = { key: string @@ -211,11 +212,7 @@ function nextQueueOrder(): number { function broadcast(event: Omit, sequenceOverride?: number): void { const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - for (const wc of webContents.getAllWebContents()) { - if (!wc.isDestroyed()) { - wc.send('gh:prRefreshEvent', payload) - } - } + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) } function refreshKey(candidate: GitHubPRRefreshCandidate): string { 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/index.ts b/src/main/index.ts index 975e3a4596b..27e9aee2aa4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,7 +26,6 @@ import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' -import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' import { classifyError } from './telemetry/classify-error' @@ -159,28 +158,21 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles import { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { - getCrashBreadcrumbSnapshot, recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' import { CrashReportStore } from './crash-reporting/crash-report-store' import { - shouldRecordProcessGoneCrash, shouldRecoverRendererAfterProcessGone, type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' -import { - buildProcessGoneCrashDetails, - buildSuppressedProcessGoneBreadcrumbData -} from './crash-reporting/process-gone-diagnostics' -import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, shouldDriveSyntheticAgentTitleFromHook, @@ -833,14 +825,6 @@ function openMainWindow(): BrowserWindow { webContentsId ) }, - shouldRecordRendererCrash: (details, webContentsId) => - shouldRecordProcessGoneCrash({ - source: 'renderer', - processType: 'renderer', - reason: details.reason, - exitCode: details.exitCode ?? null, - expectedTeardown: getExpectedTeardownScope(webContentsId) - }), shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, @@ -1229,75 +1213,14 @@ function recordProcessGoneCrash( details: Record, webContentsId?: number ): void { - if (!crashReports || !isCrashReportReason(reason)) { - return - } - if ( - !shouldRecordProcessGoneCrash({ - source, - processType, - serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId) - }) - ) { - recordCrashBreadcrumb( - 'process_gone_suppressed', - buildSuppressedProcessGoneBreadcrumbData({ - source, - processType, - reason, - exitCode, - details - }) - ) - return - } - const key = getProcessGoneDedupeKey(source, processType, reason, exitCode) - if (!processGoneDedupe.shouldRecord(key)) { - return - } - const crashDetails = buildProcessGoneCrashDetails(details) - const span = startSpan('electron.process_gone', { - attributes: { - 'crash.source': source, - 'crash.process_type': processType, - 'crash.reason': reason, - ...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}), - 'app.version': app.getVersion(), - platform: process.platform, - osRelease: os.release(), - arch: process.arch, - electronVersion: process.versions.electron, - chromeVersion: process.versions.chrome, - details: crashDetails, - breadcrumbs: getCrashBreadcrumbSnapshot() - } + recordProcessGoneCrashEvent(crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details }) - // Why: renderer/child crashes belong in the local trace lane so the - // diagnostic bundle has the same process-gone signal as the startup prompt. - span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`) - void crashReports - .record({ - source, - processType, - reason, - exitCode, - appVersion: app.getVersion(), - platform: process.platform, - osRelease: os.release(), - arch: process.arch, - electronVersion: process.versions.electron, - chromeVersion: process.versions.chrome, - details: crashDetails, - // Why: breadcrumbs stay memory-only during normal operation. Persist a - // snapshot only after Electron reports a crash-like process exit. - breadcrumbs: getCrashBreadcrumbSnapshot() - }) - .catch((error) => { - console.error('[crash-reporting] Failed to persist crash report:', error) - }) } function shutdownWatchersOnce(): Promise { diff --git a/src/main/ipc/bounded-warning-dedupe.test.ts b/src/main/ipc/bounded-warning-dedupe.test.ts new file mode 100644 index 00000000000..aa8cd623ad8 --- /dev/null +++ b/src/main/ipc/bounded-warning-dedupe.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_WARNING_DEDUPE_MAX_KEYS, shouldEmitBoundedWarning } from './bounded-warning-dedupe' + +describe('shouldEmitBoundedWarning', () => { + it('keeps retained warning keys quiet without cascade eviction after saturation', () => { + const warningKeys = new Set() + + expect(shouldEmitBoundedWarning(warningKeys, 'retained-1', 3)).toBe(true) + expect(shouldEmitBoundedWarning(warningKeys, 'retained-2', 3)).toBe(true) + expect(shouldEmitBoundedWarning(warningKeys, 'retained-3', 3)).toBe(true) + + expect(shouldEmitBoundedWarning(warningKeys, 'overflow', 3)).toBe(true) + expect(shouldEmitBoundedWarning(warningKeys, 'retained-1', 3)).toBe(false) + expect(shouldEmitBoundedWarning(warningKeys, 'retained-2', 3)).toBe(false) + expect(shouldEmitBoundedWarning(warningKeys, 'retained-3', 3)).toBe(false) + expect(shouldEmitBoundedWarning(warningKeys, 'overflow', 3)).toBe(true) + + expect([...warningKeys]).toEqual(['retained-1', 'retained-2', 'retained-3']) + }) + + it('limits repeat emissions for a stable default-cap-plus-one scan', () => { + const warningKeys = new Set() + const keys = Array.from( + { length: DEFAULT_WARNING_DEDUPE_MAX_KEYS + 1 }, + (_, index) => `warning-${index}` + ) + + expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual(keys) + expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([keys.at(-1)]) + }) +}) diff --git a/src/main/ipc/bounded-warning-dedupe.ts b/src/main/ipc/bounded-warning-dedupe.ts new file mode 100644 index 00000000000..244edcaa0ab --- /dev/null +++ b/src/main/ipc/bounded-warning-dedupe.ts @@ -0,0 +1,19 @@ +export const DEFAULT_WARNING_DEDUPE_MAX_KEYS = 256 + +// Why: diagnostic "warn once" keys often include dynamic paths/providers; keep +// repeated warnings quiet without retaining every stale key forever. +export function shouldEmitBoundedWarning( + warningKeys: Set, + key: string, + maxKeys = DEFAULT_WARNING_DEDUPE_MAX_KEYS +): boolean { + if (warningKeys.has(key)) { + return false + } + // Why: evicting during a stable max+1 scan cascades into re-emitting every warning. + if (warningKeys.size >= maxKeys) { + return true + } + warningKeys.add(key) + return true +} diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 04432899abb..2c28cfd6c77 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -185,6 +185,42 @@ describe('registerCrashReportingHandlers', () => { expect(listRecent).not.toHaveBeenCalled() }) + it('copies sanitized submission and diagnostic omission failures for a captured report', async () => { + const pending = report('pending', 'crash-copy-failure') + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, { + reportId: pending.id, + notes: 'current notes', + submissionFailure: { + error: 'fallback failed at C:\\Users\\alice\\Orca', + diagnosticContext: { + status: 'not_uploaded', + reason: 'attachment token=super-secret-value', + internalEndpointError: 'must-not-cross-copy-boundary' + }, + diagnosticBundleFailure: 'must-not-cross-copy-boundary' + } + }) + + expect(result).toEqual({ ok: true }) + const copiedText = String(clipboardWriteTextMock.mock.calls[0]?.[0]) + expect(copiedText).toContain('Report ID: crash-copy-failure') + expect(copiedText).toContain('Submission failure:') + expect(copiedText).toContain('Report error: fallback failed at [redacted-path]') + expect(copiedText).toContain('Diagnostic logs not uploaded: attachment token=[redacted]') + expect(copiedText).not.toContain('alice') + expect(copiedText).not.toContain('must-not-cross-copy-boundary') + }) + it('returns dismissed unsent reports for the manual Help menu entry', async () => { const dismissed = report('dismissed', 'crash-help-menu') registerCrashReportingHandlers({ @@ -244,7 +280,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(markSent).toHaveBeenCalledWith(pending.id) }) @@ -294,7 +331,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(submitFeedbackMock).toHaveBeenCalledWith( expect.objectContaining({ feedback: expect.stringContaining('Status: attached') }) @@ -347,6 +385,52 @@ describe('registerCrashReportingHandlers', () => { ) }) + it('marks the report sent when transport retries successfully without diagnostic logs', async () => { + const pending = report('pending', 'crash-degraded') + const sent = report('sent', pending.id) + const markSent = vi.fn(async () => sent) + submitFeedbackMock.mockResolvedValueOnce({ + ok: true, + diagnosticBundleFailure: { status: 413, error: 'status 413' } + }) + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent, + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: pending.id, + notes: 'manual report', + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + + expect(result).toEqual({ + ok: true, + report: sent, + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: status 413', + bundleSubmissionId: 'bundleabcdefghijklmnop', + bytes: 25, + spanCount: 1 + } + }) + expect(markSent).toHaveBeenCalledWith(pending.id) + expect(submitFeedbackMock).toHaveBeenCalledWith( + expect.objectContaining({ + feedback: expect.stringContaining('Status: attached'), + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') + }) + ) + }) + it('submits the crash report without logs when the user excludes diagnostic logs', async () => { registerCrashReportingHandlers({ getById: vi.fn(async () => null), @@ -459,7 +543,8 @@ describe('registerCrashReportingHandlers', () => { content: diagnosticBundle().payload, bytes: 25, spanCount: 1 - } + }, + feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded') }) expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id) }) @@ -492,8 +577,9 @@ describe('registerCrashReportingHandlers', () => { const markSent = vi.fn() submitFeedbackMock.mockResolvedValue({ ok: false, - status: 500, - error: 'status 500' + status: null, + error: 'report-only network failed', + diagnosticBundleFailure: { status: 500, error: 'status 500' } }) registerCrashReportingHandlers({ getById: vi.fn(async () => pending), @@ -514,10 +600,18 @@ describe('registerCrashReportingHandlers', () => { expect(result).toEqual({ ok: false, - status: 500, - error: 'status 500', - report: pending + status: null, + error: 'report-only network failed', + report: pending, + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: status 500', + bundleSubmissionId: 'bundleabcdefghijklmnop', + bytes: 25, + spanCount: 1 + } }) + expect(result).not.toHaveProperty('diagnosticBundleFailure') expect(submitFeedbackMock).toHaveBeenCalledWith( expect.objectContaining({ diagnosticBundle: { diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 7d60da1799f..ac424d834a8 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -4,6 +4,7 @@ import os from 'node:os' import { app, clipboard, ipcMain } from 'electron' import { type CrashReportBreadcrumbData, + type CrashReportCopyDiagnosticsArgs, type CrashReportDiagnosticBundle, type ReactErrorBoundaryReportArgs, type ReactErrorBoundaryReportResult, @@ -20,14 +21,17 @@ import { getCrashBreadcrumbSnapshot, recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability' -import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint' import { startSpan } from '../observability/tracer' -import type { FeedbackDiagnosticBundleAttachment } from './feedback' +import { + diagnosticBundleForReportOnlyRetry, + prepareCrashDiagnosticBundle, + resolveSubmittedDiagnosticBundle +} from '../crash-reporting/crash-feedback-diagnostic-bundle' import { assertClipboardTextWriteWithinLimit, isClipboardTextWriteTooLargeError } from '../../shared/clipboard-text' +import { formatCrashReportCopyText } from '../crash-reporting/crash-report-copy-text' const inFlightSubmissions = new Set() const submittedReportIds = new Set() @@ -37,7 +41,6 @@ const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000 const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2 const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256 const MAX_SUBMITTED_REPORT_IDS = 256 -const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60 const REACT_ERROR_BOUNDARY_SURFACES = new Set([ 'app-root', @@ -52,11 +55,6 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set - try { - bundle = collectDiagnosticBundle({ - appVersion: app.getVersion(), - platform: os.platform(), - arch: os.arch(), - osRelease: os.release(), - orcaChannel: resolveDiagnosticOrcaChannel(), - // Why: Help > Report Crash is often used after relaunch, long after the - // default 30 minute support bundle window would miss the failure context. - lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES - }) - } catch (error) { - return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } } - } - - return { - diagnosticBundle: { - status: 'attached', - bundleSubmissionId: bundle.bundleSubmissionId, - bytes: bundle.bytes, - spanCount: bundle.spanCount - }, - feedbackDiagnosticBundle: { - bundleSubmissionId: bundle.bundleSubmissionId, - content: bundle.payload, - bytes: bundle.bytes, - spanCount: bundle.spanCount - } - } -} - export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:getLatestPending') ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) @@ -412,15 +354,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:copyLatestDiagnostics') ipcMain.handle( 'crashReports:copyLatestDiagnostics', - async (_event, args?: { reportId?: string; notes?: string }) => { + async (_event, args?: CrashReportCopyDiagnosticsArgs) => { const report = await getRequestedCrashReport(store, args) - if (!report) { - clipboard.writeText(buildUncapturedCrashReportText(args?.notes)) - return { ok: true as const } - } + const baseText = report + ? formatCrashReportText(report, args?.notes) + : buildUncapturedCrashReportText(args?.notes) try { clipboard.writeText( - assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes)) + assertClipboardTextWriteWithinLimit( + formatCrashReportCopyText(baseText, args?.submissionFailure) + ) ) } catch (error) { if (isClipboardTextWriteTooLargeError(error)) { @@ -448,11 +391,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { async (_event, args: CrashReportSubmitArgs): Promise => { const report = await getRequestedCrashReport(store, args) if (!report) { - const diagnosticUpload = - args.includeDiagnosticLogs === false - ? skippedCrashDiagnosticBundle() - : collectCrashDiagnosticBundleAttachment() + const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false) const diagnosticBundle = diagnosticUpload.diagnosticBundle + const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload) const result = await submitFeedback({ feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle), submissionType: 'crash', @@ -460,14 +401,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { githubLogin: args.githubLogin, githubEmail: args.githubEmail, ...(diagnosticUpload.feedbackDiagnosticBundle - ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle } + ? { + diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle, + feedbackWithoutDiagnosticBundle: buildUncapturedCrashReportText( + args.notes, + reportOnlyDiagnosticBundle + ) + } : {}) }) + const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result) return result.ok - ? { ok: true, report: null, diagnosticBundle } + ? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle } : { - ...result, - report: null + // Why: the transport-only attachment failure may contain raw + // endpoint detail; only its sanitized bundle reason crosses IPC. + ok: false, + status: result.status, + error: result.error, + report: null, + diagnosticBundle: submittedDiagnosticBundle } } const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed') @@ -491,11 +444,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { inFlightSubmissions.add(report.id) try { - const diagnosticUpload = - args.includeDiagnosticLogs === false - ? skippedCrashDiagnosticBundle() - : collectCrashDiagnosticBundleAttachment() + const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false) const diagnosticBundle = diagnosticUpload.diagnosticBundle + const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload) const result = await submitFeedback({ feedback: formatCrashReportText(report, args.notes, diagnosticBundle), submissionType: 'crash', @@ -503,13 +454,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { githubLogin: args.githubLogin, githubEmail: args.githubEmail, ...(diagnosticUpload.feedbackDiagnosticBundle - ? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle } + ? { + diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle, + feedbackWithoutDiagnosticBundle: formatCrashReportText( + report, + args.notes, + reportOnlyDiagnosticBundle + ) + } : {}) }) + const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result) if (!result.ok) { return { - ...result, - report + // Why: keep the renderer contract allow-listed instead of leaking + // the transport's internal diagnosticBundleFailure object. + ok: false, + status: result.status, + error: result.error, + report, + diagnosticBundle: submittedDiagnosticBundle } } rememberSubmittedReportId(report.id) @@ -518,21 +482,37 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { // Why: startup prompts are dismissed before the user can send from // the still-open dialog, so successful uploads must update storage. const sent = await store.markDismissedSent(report.id) - return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: sent ?? { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } catch (error) { console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error) - return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } } try { const sent = await store.markSent(report.id) - return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: sent ?? { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } catch (error) { // Why: the upstream submission already succeeded. A local persistence // failure must not present as upload failure or invite duplicate sends // during this app session. console.error('[crash-reporting] Failed to mark crash report sent:', error) - return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle } + return { + ok: true, + report: { ...report, status: 'sent' }, + diagnosticBundle: submittedDiagnosticBundle + } } } finally { inFlightSubmissions.delete(report.id) diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts index 1d8c3053885..52de8251d1b 100644 --- a/src/main/ipc/feedback.test.ts +++ b/src/main/ipc/feedback.test.ts @@ -22,9 +22,34 @@ function okResponse(): Response { return { ok: true, status: 200 } as unknown as Response } -function postedBody(): Record { - const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined - return JSON.parse(String(init?.body)) as Record +function errorResponse(status: number): Response { + return { ok: false, status } as unknown as Response +} + +function requestInit(callIndex = 0): RequestInit { + return fetchMock.mock.calls[callIndex]?.[1] as RequestInit +} + +function postedBody(callIndex = 0): Record { + return JSON.parse(String(requestInit(callIndex).body)) as Record +} + +function diagnosticSubmitArgs(): Parameters[0] { + return { + feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached', + feedbackWithoutDiagnosticBundle: + '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', + submissionType: 'crash', + submitAnonymously: true, + githubLogin: null, + githubEmail: null, + diagnosticBundle: { + bundleSubmissionId: 'bundleabcdefghijklmnop', + content: '{"type":"bundle-header"}\n', + bytes: 25, + spanCount: 1 + } + } } describe('submitFeedback', () => { @@ -139,6 +164,118 @@ describe('submitFeedback', () => { expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle') }) + it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 413, error: 'status 413' } + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(0).body).toBeInstanceOf(FormData) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(1).headers).toEqual({ + 'Content-Type': 'application/json' + }) + expect(postedBody(1)).toMatchObject({ + feedback: + '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', + submissionType: 'crash' + }) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a diagnostic attachment server error as report-only JSON on the fallback API', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(500)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 500, error: 'status 500' } + }) + + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' }) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a diagnostic attachment network error as report-only JSON on the fallback API', async () => { + fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) + fetchMock.mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: null, error: 'attachment network failed' } + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(requestInit(1).body).not.toBeInstanceOf(FormData) + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => { + vi.useFakeTimers() + fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text'))) + }) + }) + fetchMock.mockResolvedValueOnce(okResponse()) + const result = submitFeedback(diagnosticSubmitArgs()) + + await vi.advanceTimersByTimeAsync(59_999) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(result).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' } + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback') + expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') + }) + + it('retries a proxy-rejected diagnostic attachment as website JSON', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse()) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: true, + diagnosticBundleFailure: { status: 403, error: 'status 403' } + }) + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') + expect(requestInit(1).body).not.toBeInstanceOf(FormData) + }) + + it.each([401, 409, 429])( + 'does not retry a diagnostic attachment rejected with status %s', + async (status) => { + fetchMock.mockResolvedValueOnce(errorResponse(status)) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: false, + status, + error: `status ${status}` + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it('preserves attachment and report-only failures when the degraded retry fails', async () => { + fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) + fetchMock.mockRejectedValueOnce(new Error('report-only network failed')) + + await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ + ok: false, + status: null, + error: 'report-only network failed', + diagnosticBundleFailure: { status: null, error: 'attachment network failed' } + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('falls back when the primary feedback request stalls', async () => { vi.useFakeTimers() fetchMock.mockImplementation((url: string, init?: RequestInit) => { @@ -184,7 +321,7 @@ describe('submitFeedback', () => { await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: false, status: null, - error: 'fallback aborted' + error: 'request timed out after 10 seconds' }) expect(fetchMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index 16c159f6a4c..d9fa48e3c47 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -9,7 +9,11 @@ import { app, ipcMain, net } from 'electron' const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback' const FEEDBACK_API_FALLBACK_URL = 'https://api.onorca.dev/v1/feedback' const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000 +const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000 const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson' +// Why: corporate filters can reject multipart with 403 while allowing the +// small JSON report, so content-shaped failures should shed the attachment. +const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422]) export type FeedbackSubmissionType = 'feedback' | 'crash' @@ -39,13 +43,21 @@ type FeedbackSubmitBody = { diagnosticBundle?: FeedbackDiagnosticBundleAttachment } +export type FeedbackRequestFailure = { + status: number | null + error: string +} + export type FeedbackSubmitResult = - | { ok: true } - | { ok: false; status: number | null; error: string } + | { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure } + | ({ ok: false } & FeedbackRequestFailure & { + diagnosticBundleFailure?: FeedbackRequestFailure + }) type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & { submissionType?: FeedbackSubmissionType diagnosticBundle?: FeedbackDiagnosticBundleAttachment + feedbackWithoutDiagnosticBundle?: string } // Why: the Slack notification and any follow-up investigation need to know @@ -74,11 +86,15 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { } } -async function postFeedback(url: string, body: FeedbackSubmitBody): Promise { +async function postFeedback( + url: string, + body: FeedbackSubmitBody, + timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS +): Promise { const controller = new AbortController() // Why: a silent feedback endpoint should not leave IPC or crash-report // submission flows pending forever. - const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const init: RequestInit = { method: 'POST', @@ -86,6 +102,13 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise= 500) { + return FEEDBACK_API_FALLBACK_URL + } + return null +} + +async function submitFeedbackWithoutDiagnosticBundle( + url: string, + body: FeedbackSubmitBody, + diagnosticBundleFailure: FeedbackRequestFailure +): Promise { + try { + const response = await postFeedback(url, body) + if (response.ok) { + return { ok: true, diagnosticBundleFailure } + } + return { ok: false, ...responseFailure(response), diagnosticBundleFailure } + } catch (error) { + return { ok: false, ...errorFailure(error), diagnosticBundleFailure } + } +} + +async function submitFeedbackWithDiagnosticBundle( + body: FeedbackSubmitBody, + bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null +): Promise { + try { + // Why: diagnostic bundles can approach 4 MiB and need more upload time than + // the small JSON report-only path, especially on constrained connections. + const response = await postFeedback( + FEEDBACK_API_URL, + body, + FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS + ) + if (response.ok) { + return { ok: true } + } + const failure = responseFailure(response) + if (bodyWithoutDiagnosticBundle) { + const retryUrl = diagnosticRetryUrl(response.status) + if (retryUrl) { + return submitFeedbackWithoutDiagnosticBundle(retryUrl, bodyWithoutDiagnosticBundle, failure) + } + } + return { ok: false, ...failure } + } catch (error) { + const failure = errorFailure(error) + return bodyWithoutDiagnosticBundle + ? submitFeedbackWithoutDiagnosticBundle( + FEEDBACK_API_FALLBACK_URL, + bodyWithoutDiagnosticBundle, + failure + ) + : { ok: false, ...failure } + } +} + export async function submitFeedback( args: InternalFeedbackSubmitArgs ): Promise { const body = buildSubmitBody(args) + if (body.diagnosticBundle) { + const bodyWithoutDiagnosticBundle = + args.feedbackWithoutDiagnosticBundle !== undefined + ? buildSubmitBody({ + ...args, + feedback: args.feedbackWithoutDiagnosticBundle, + diagnosticBundle: undefined + }) + : null + return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle) + } try { const res = await postFeedback(FEEDBACK_API_URL, body) if (res.ok) { 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.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..4b5c4cd1495 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,20 +1,13 @@ import { lstat, readdir, realpath } from 'node:fs/promises' import { basename, join, posix, resolve } from 'node:path' -import type { SFTPWrapper } from 'ssh2' 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' -// 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 +32,30 @@ 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() 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 + ) results.push(result) if (result.status === 'imported') { // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). @@ -66,15 +64,16 @@ 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 @@ -126,18 +125,22 @@ async function importOneSourceSsh( let createdDestDir: string | null = null try { - const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames) + 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 uploadDirectoryViaProvider( + provider, + uploadSession, + resolvedSource, + destPath, + await realpath(resolvedSource) + ) } else { - await uploadFile(sftp, resolvedSource, destPath, { exclusive: true }) + await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) } return { @@ -151,7 +154,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 +164,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 +183,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,44 +207,72 @@ 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) +async function uploadDirectoryViaProvider( + provider: IFilesystemProvider, + uploadSession: FileUploadSession, + localDir: string, + remoteDir: string, + rootRealPath: string +): Promise { + await assertLocalUploadPathInsideRoot(rootRealPath, localDir) + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + const localPath = join(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) - writeStream.on('close', onClose) - writeStream.on('error', onError) - writeStream.end(contents) - }) + // 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 uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath) + continue + } + await uploadSession.uploadFile(localPath, remotePath, { exclusive: 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 + } +} + +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 + ) + ) } async function preScanForSymlinks(dirPath: string): Promise { @@ -253,3 +290,21 @@ async function preScanForSymlinks(dirPath: string): Promise { } return false } + +async function assertLocalUploadPathInsideRoot( + rootRealPath: string, + candidatePath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const root = rootRealPath.replace(/[\\/]+$/g, '') + const candidate = candidateRealPath.replace(/[\\/]+$/g, '') + const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root + const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate + if (candidateComparable === rootComparable) { + return + } + const boundary = process.platform === 'win32' ? '\\' : '/' + if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) { + throw new Error(`Upload source escapes selected directory: ${candidatePath}`) + } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 072f8239dcb..2e9d1843988 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -907,6 +907,35 @@ describe('registerFilesystemHandlers', () => { }) }) + it('returns stable byte metadata only for opted-in local log snapshots', async () => { + const content = Buffer.from('first\npartial') + const close = vi.fn() + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + dev: 1, + ino: 2, + birthtimeMs: 3 + }), + readFile: vi.fn().mockResolvedValue(content), + close + }) + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { + filePath: path.resolve('/workspace/repo/session.jsonl'), + includeLocalLogMetadata: true + }) + ).resolves.toEqual({ + content: 'first\npartial', + isBinary: false, + fileIdentity: '1:2:3' + }) + expect(close).toHaveBeenCalledTimes(1) + expect(readFileMock).not.toHaveBeenCalled() + }) + it('rejects text files beyond the editor read budget', async () => { statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 100245dca8b..3918f8d3ea4 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -120,6 +120,8 @@ import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-er import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { registerLocalLogTailHandlers } from './local-log-tail' +import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -148,6 +150,38 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } function validateRequiredString(value: unknown, label: string): string { @@ -534,13 +568,22 @@ export function registerFilesystemHandlers( 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => { + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE @@ -2216,4 +2259,6 @@ export function registerFilesystemHandlers( return getRemoteCommitUrl(worktreePath, sha) } ) + + registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 768981cec19..fef0cf7aace 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -47,6 +47,7 @@ const { trackMock, getCohortAtEmitMock, getAllWebContentsMock, + sendToTrustedUIRendererMock, clearVisiblePRRefreshWindowMock, enqueuePRRefreshMock, refreshPRNowMock, @@ -88,6 +89,7 @@ const { trackMock: vi.fn(), getCohortAtEmitMock: vi.fn(), getAllWebContentsMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), clearVisiblePRRefreshWindowMock: vi.fn(), enqueuePRRefreshMock: vi.fn(), refreshPRNowMock: vi.fn(), @@ -157,6 +159,10 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('./ui', () => ({ + sendToTrustedUIRenderer: sendToTrustedUIRendererMock +})) + import { registerGitHubHandlers } from './github' import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' @@ -233,6 +239,7 @@ describe('registerGitHubHandlers', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) getAllWebContentsMock.mockReset() getAllWebContentsMock.mockReturnValue([]) + sendToTrustedUIRendererMock.mockReset() clearVisiblePRRefreshWindowMock.mockReset() enqueuePRRefreshMock.mockReset() refreshPRNowMock.mockReset() @@ -282,12 +289,15 @@ describe('registerGitHubHandlers', () => { ) }) - it('broadcasts mutation notifications by repo id even when the payload path is remote', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets mutation notifications without broadcasting to 100 browser guests', async () => { + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) registerGitHubHandlers(store as never, stats as never) const result = await handlers['gh:notifyWorkItemMutated']( @@ -301,20 +311,22 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'pr', - number: 42 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledOnce() + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'pr', + number: 42 + }, + 1 + ) + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) }) - it('broadcasts mutation notifications with resolved repo id when called by repo path', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets mutation notifications with resolved repo id when called by repo path', async () => { registerGitHubHandlers(store as never, stats as never) const result = await handlers['gh:notifyWorkItemMutated']( @@ -327,20 +339,19 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'issue', - number: 7 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'issue', + number: 7 + }, + 1 + ) }) - it('broadcasts PR file viewed mutations with repo id for sibling cache invalidation', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets PR file viewed mutations with repo id for cache invalidation', async () => { setPRFileViewedMock.mockResolvedValue(true) registerGitHubHandlers(store as never, stats as never) @@ -356,12 +367,16 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'pr', - number: 42 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'pr', + number: 42 + }, + 1 + ) }) it('rejects unknown repository paths', async () => { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 0d64c2512bf..a95fb887e9c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -2,7 +2,7 @@ the repo-path validation, preference-threading, and stats wiring patterns are reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ -import { ipcMain, webContents } from 'electron' +import { ipcMain } from 'electron' import { resolve } from 'node:path' import type { Repo, @@ -108,15 +108,12 @@ import type { import { appStarSourceSchema } from '../../shared/gh-star-source' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { sendToTrustedUIRenderer } from './ui' const prRefreshVisibilityCleanupRegistered = new Set() -// Why: notify every renderer (each window has its own SWR cache instance) -// that a work item was mutated locally so they can drop their cached entry -// and refetch on the next open. Only emitted after a successful mutation. -// We skip the originating webContents because that renderer already updated -// its cache optimistically — re-broadcasting would race the optimistic write -// and erase it. +// Why: the app renderer owns the SWR cache; browser guests cannot consume this +// event. Skip the origin because it already updated its cache optimistically. function broadcastWorkItemMutated( payload: { repoPath: string @@ -126,15 +123,7 @@ function broadcastWorkItemMutated( }, senderId?: number ): void { - for (const wc of webContents.getAllWebContents()) { - if (wc.isDestroyed()) { - continue - } - if (senderId !== undefined && wc.id === senderId) { - continue - } - wc.send('gh:workItemMutated', payload) - } + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) } // Why: returns the full Repo object instead of just the path string so that diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts new file mode 100644 index 00000000000..beac464f4a4 --- /dev/null +++ b/src/main/ipc/local-log-tail.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, watchMock, resolveAuthorizedPathMock, readRangeMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + watchMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + readRangeMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('node:fs', () => ({ watch: watchMock })) + +vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) + +vi.mock('../ai-vault/local-log-tail-reader', () => ({ + readLocalLogTailRange: readRangeMock +})) + +import { + closeAllLocalLogTailWatchers, + getActiveLocalLogTailWatcherCount, + registerLocalLogTailHandlers +} from './local-log-tail' + +type FakeWatcher = { + close: ReturnType + on: ReturnType + emitError: () => void +} + +function makeWatcher(): FakeWatcher { + let errorListener: (() => void) | undefined + return { + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === 'error') { + errorListener = listener + } + }), + emitError: () => errorListener?.() + } +} + +function makeSender(id: number) { + let destroyedListener: (() => void) | undefined + return { + id, + send: vi.fn(), + isDestroyed: vi.fn(() => false), + once: vi.fn((event: string, listener: () => void) => { + if (event === 'destroyed') { + destroyedListener = listener + } + }), + destroy: () => destroyedListener?.() + } +} + +beforeEach(() => { + handlers.clear() + watchMock.mockReset() + resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) + readRangeMock.mockReset() + registerLocalLogTailHandlers({} as never) +}) + +afterEach(() => { + closeAllLocalLogTailWatchers() +}) + +describe('local log tail IPC', () => { + it('watches only the authorized file and closes on explicit tab cancellation', async () => { + const watcher = makeWatcher() + let emitChange: ((eventType: 'change' | 'rename') => void) | undefined + watchMock.mockImplementation((_path: string, listener: typeof emitChange) => { + emitChange = listener + return watcher + }) + const sender = makeSender(7) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/session.jsonl', subscriptionId: 'tail-1' } + ) + emitChange?.('change') + + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) + expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { + subscriptionId: 'tail-1', + eventType: 'change' + }) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + + handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId: 'tail-1' }) + expect(watcher.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) + + it('closes every watcher owned by a destroyed renderer', async () => { + const first = makeWatcher() + const second = makeWatcher() + watchMock.mockReturnValueOnce(first).mockReturnValueOnce(second) + const sender = makeSender(9) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/a.jsonl', subscriptionId: 'tail-a' } + ) + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/b.jsonl', subscriptionId: 'tail-b' } + ) + sender.destroy() + + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) +}) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts new file mode 100644 index 00000000000..430882b4e07 --- /dev/null +++ b/src/main/ipc/local-log-tail.ts @@ -0,0 +1,109 @@ +import { ipcMain, type WebContents } from 'electron' +import { watch, type FSWatcher } from 'node:fs' +import type { Store } from '../persistence' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' +import { resolveAuthorizedPath } from './filesystem-auth' + +type TailWatch = { + senderId: number + watcher: FSWatcher +} + +const tailWatches = new Map() +const senderCleanupRegistered = new Set() + +function watchKey(senderId: number, subscriptionId: string): string { + return `${senderId}:${subscriptionId}` +} + +function closeWatch(key: string): void { + const subscription = tailWatches.get(key) + if (!subscription) { + return + } + tailWatches.delete(key) + subscription.watcher.close() +} + +function closeSenderWatches(senderId: number): void { + senderCleanupRegistered.delete(senderId) + for (const [key, subscription] of tailWatches) { + if (subscription.senderId === senderId) { + closeWatch(key) + } + } +} + +function validateSubscriptionId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 200) { + throw new Error('Invalid local log tail subscription id') + } + return value +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + sender.once('destroyed', () => closeSenderWatches(sender.id)) +} + +export function registerLocalLogTailHandlers(store: Store): void { + ipcMain.handle( + 'fs:readLocalLogTail', + async (_event, args: LocalLogTailReadArgs): Promise => { + const filePath = await resolveAuthorizedPath(args.filePath, store) + return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) + } + ) + + ipcMain.handle( + 'fs:startLocalLogTail', + async (event, args: LocalLogTailWatchArgs): Promise => { + const subscriptionId = validateSubscriptionId(args.subscriptionId) + const filePath = await resolveAuthorizedPath(args.filePath, store) + const key = watchKey(event.sender.id, subscriptionId) + closeWatch(key) + + const sendChange = (eventType: 'change' | 'rename'): void => { + if (!tailWatches.has(key) || event.sender.isDestroyed()) { + return + } + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + event.sender.send('fs:localLogTailChanged', payload) + } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + watcher.on('error', () => { + // Why: an error commonly accompanies rotation. Signal one final drain so + // the renderer can detect identity change, then release the dead handle. + sendChange('rename') + closeWatch(key) + }) + tailWatches.set(key, { senderId: event.sender.id, watcher }) + registerSenderCleanup(event.sender) + } + ) + + ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { + closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))) + }) +} + +export function closeAllLocalLogTailWatchers(): void { + for (const key of Array.from(tailWatches.keys())) { + closeWatch(key) + } + senderCleanupRegistered.clear() +} + +/** Test-only: verifies tab/window teardown does not retain native watchers. */ +export function getActiveLocalLogTailWatcherCount(): number { + return tailWatches.size +} 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/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.test.ts b/src/main/ipc/pty.test.ts index 66a0c1c0750..35d386eb473 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 @@ -555,6 +562,7 @@ describe('registerPtyHandlers', () => { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), onData: vi.fn(() => () => {}), @@ -596,6 +604,7 @@ describe('registerPtyHandlers', () => { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => { @@ -1679,6 +1688,28 @@ 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 { + const env = await daemonSpawnAndGetEnv({ PATH: '/usr/local/bin:/usr/bin' }) + 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')) + } 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') @@ -3219,6 +3250,40 @@ describe('registerPtyHandlers', () => { }) }) + 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('rethrows non-not-found local provider shutdown failures', async () => { setLocalPtyProvider({ spawn: vi.fn(), @@ -3973,6 +4038,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), getDefaultShell: vi.fn(), @@ -4034,6 +4100,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), getDefaultShell: vi.fn(), @@ -4050,8 +4117,12 @@ describe('registerPtyHandlers', () => { await expect( handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' }) ).resolves.toBeNull() + await expect( + handlers.get('pty:confirmForegroundProcess')!(null, { id: 'remote-pty' }) + ).resolves.toBeNull() expect(provider.hasChildProcesses).not.toHaveBeenCalled() expect(provider.getForegroundProcess).not.toHaveBeenCalled() + expect(provider.confirmForegroundProcess).not.toHaveBeenCalled() }) // Why: regression for the Claude-Code split-pane garbled-render desync. resize @@ -4067,12 +4138,13 @@ describe('registerPtyHandlers', () => { applied: { cols: number; rows: number } | null resize?: (cols: number, rows: number) => void getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> - }): void { + }): ReturnType { + const write = vi.fn() setLocalPtyProvider({ spawn: vi.fn(async (opts: { sessionId?: string }) => ({ id: opts.sessionId ?? 'daemon-pty' })), - write: vi.fn(), + write, resize: vi.fn(args.resize ?? (() => {})), getAppliedSize: vi.fn(args.getAppliedSize ?? (async () => args.applied)), kill: vi.fn(), @@ -4082,6 +4154,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + return write } const resizeListener = (): ((event: unknown, args: unknown) => void) => { @@ -4209,6 +4282,113 @@ describe('registerPtyHandlers', () => { expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() }) + + it('suppresses the host fit cascade while a remote viewer drives the width', async () => { + const resizeSpy = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + // The whole point of the fix: a PTY with a remote viewer reports true, + // even though the presence-lock driver state stays idle/desktop. + isRemoteDesktopResizeDriven: vi.fn(() => true), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + recordRemoteDesktopHostReclaimTarget: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + resizeSpy.mockClear() + + // Host's own safeFit tries to widen the viewed PTY back to its window. + resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + + // It must not reach the PTY while the viewer owns the width. + expect(resizeSpy).not.toHaveBeenCalled() + expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48) + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) + + it('lets trusted host activity reclaim remote viewport ownership', () => { + const claimRemoteDesktopHost = vi.fn().mockResolvedValue(true) + const runtime = { + setPtyController: vi.fn(), + claimRemoteDesktopHost + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport') + const claimListener = call?.[1] as + | ((event: unknown, args: { id: string; cols: number; rows: number }) => void) + | undefined + expect(claimListener).toBeTypeOf('function') + + claimListener?.(mainWindowIpcEvent, { id: 'pty-1', cols: 125, rows: 48 }) + + expect(claimRemoteDesktopHost).toHaveBeenCalledWith('pty-1', 125, 48) + }) + + it('does not forward host input when viewport reclaim fails', async () => { + const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + claimRemoteDesktopHost: vi.fn().mockResolvedValue(false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + const claim = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport') + const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write') + + claim?.[1](mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + writeEvent?.[1](mainWindowIpcEvent, { id, data: 'x' }) + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + }) + + it('does not populate the remote reclaim cache when only a phone drives', async () => { + const resizeSpy = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'mobile', clientId: 'phone-A' })), + isRemoteDesktopResizeDriven: vi.fn(() => false), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + recordRemoteDesktopHostReclaimTarget: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + resizeSpy.mockClear() + + resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + + expect(resizeSpy).not.toHaveBeenCalled() + expect(runtime.recordRemoteDesktopHostReclaimTarget).not.toHaveBeenCalled() + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3686aaad2ad..166a308e96d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -34,6 +34,7 @@ import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { isTuiAgent } from '../../shared/tui-agent-config' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { @@ -78,6 +79,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' @@ -1005,6 +1007,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 @@ -1495,6 +1509,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:hasPty') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:confirmForegroundProcess') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:declarePendingPaneSerializer') @@ -3414,6 +3429,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) @@ -3937,6 +3961,9 @@ export function registerPtyHandlers( if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } + if (isTuiAgent(args.launchAgent)) { + spawnOptions.launchAgent = args.launchAgent + } if (args.worktreeId !== undefined) { spawnOptions.worktreeId = args.worktreeId } @@ -4457,6 +4484,7 @@ export function registerPtyHandlers( } type PtyWritePayload = { id: string; data: string } + type PtyViewportClaimPayload = { id: string; cols: number; rows: number } const isPtyWritePayload = (value: unknown): value is PtyWritePayload => typeof value === 'object' && @@ -4465,6 +4493,18 @@ export function registerPtyHandlers( (value as { id: string }).id.length > 0 && typeof (value as { data?: unknown }).data === 'string' + const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { cols?: unknown }).cols === 'number' && + Number.isFinite((value as { cols: number }).cols) && + typeof (value as { rows?: unknown }).rows === 'number' && + Number.isFinite((value as { rows: number }).rows) && + (value as { cols: number }).cols > 0 && + (value as { rows: number }).rows > 0 + const isPtyWriteEventFromMainWindow = ( event: IpcMainEvent | IpcMainInvokeEvent, mainWebContents: WebContents @@ -4528,17 +4568,55 @@ export function registerPtyHandlers( } } + const hostViewportClaimTails = new Map>() + ipcMain.on('pty:write', (event, args: unknown) => { if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return } + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + return + } writePtyInput(args) }) ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return false } - return writePtyInputAccepted(args) + const claimTail = hostViewportClaimTails.get(args.id) + return claimTail + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) + : writePtyInputAccepted(args) + }) + + ipcMain.removeAllListeners('pty:claimViewport') + ipcMain.on('pty:claimViewport', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !runtime || + !isPtyViewportClaimPayload(args) + ) { + return + } + const prior = hostViewportClaimTails.get(args.id) + // Why: two panes can mirror one PTY. Never let a later no-op claim replace + // the in-flight resize that the following host input must await. + const claim = ( + prior + ? prior.then( + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ).catch(() => false) + hostViewportClaimTails.set(args.id, claim) + void claim.then(() => { + if (hostViewportClaimTails.get(args.id) === claim) { + hostViewportClaimTails.delete(args.id) + } + }) }) // Why: resize is fire-and-forget — the renderer doesn't need a reply. @@ -4554,14 +4632,22 @@ export function registerPtyHandlers( if (runtime?.isResizeSuppressed()) { return } - // Why: presence-lock defense-in-depth. While mobile is driving, - // desktop-side resizes (auto-fit on window resize, split drag) must - // not reach the PTY. The renderer guard checks the driver state too, - // but this is the load-bearing layer because the renderer mirror lags - // by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles - // the safeFit cascade after take-back; this driver check handles the - // ongoing locked state. See docs/mobile-presence-lock.md. - if (runtime?.getDriver(args.id).kind === 'mobile') { + // Why: presence-lock defense-in-depth. While a phone OR a remote desktop + // viewer drives the PTY width, the host's own desktop-side resizes + // (auto-fit on window resize, split drag, tab reveal, "+"-new-tab + // re-render) must not reach the PTY — otherwise they overwrite the remote + // viewer's grid and its alt-screen TUI garbles ("porridge"). The renderer + // guard checks the driver state too, but this is the load-bearing layer + // because the renderer mirror lags by one IPC hop. Note: BOTH guards apply + // — isResizeSuppressed handles the safeFit cascade after take-back; this + // driver check handles the ongoing locked state. See + // docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } return } const provider = tryGetProviderForPty(args.id) @@ -4978,6 +5064,19 @@ export function registerPtyHandlers( } ) + ipcMain.handle( + 'pty:confirmForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return null + } + const provider = getProviderForPty(args.id) + // Why: falling back to the cached foreground API would turn stale + // process identity into shell/agent authority at a command boundary. + return provider.confirmForegroundProcess?.(args.id) ?? null + } + ) + // Why: renderer needs the live shell cwd when the user presses Cmd+D so // the new split pane inherits the source pane's cwd instead of the // worktree root. Routed through getProviderForPty so local and SSH PTYs diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 2a0336d8c12..eee64eff2eb 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -21,6 +21,7 @@ const { mockGitProvider, mockFilesystemProvider, mockMultiplexer, + gitExecFileAsyncMock, gitSpawnMock, listWorktreeGraphMock, invalidateAuthorizedRootsCacheMock, @@ -72,6 +73,7 @@ const { notify: vi.fn() }, gitSpawnMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), listWorktreeGraphMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn(), prepareLocalWorktreeRootForRepoMock: vi.fn() @@ -104,10 +106,13 @@ vi.mock('../git/repo', async () => { }) vi.mock('../git/runner', async () => ({ - // Why: keep the real nonInteractiveGitEnv so the clone regression test - // (#7652) asserts the actual guard's markers, not a mock echoing itself. + // Why: keep the real env builders (nonInteractiveGitEnv, + // gitOptionalLocksDisabledEnv) so the clone regression test (#7652) asserts + // the actual guard's markers, not a mock echoing itself. ...(await vi.importActual('../git/runner')), - gitExecFileAsync: vi.fn(), + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitStreamStdout: vi.fn(), gitSpawn: gitSpawnMock })) @@ -151,6 +156,7 @@ vi.mock('./ssh', () => ({ })) import { registerRepoHandlers } from './repos' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' beforeEach(() => { clearGitCapabilityStateForTests() @@ -1015,6 +1021,8 @@ describe('repos:addRemote', () => { mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() gitSpawnMock.mockReset() + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + clearSubmodulePathsCacheForTests() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) gitSpawnMock.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } @@ -1973,6 +1981,41 @@ describe('repos:add + repos:clone', () => { expect(result).toHaveProperty('externalWorktreeVisibility', 'hide') }) + it('drops a same-path negative submodule cache before a local clone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + let cloned = false + gitExecFileAsyncMock.mockImplementation((args: string[]) => + Promise.resolve({ + stdout: + args[0] === 'config' && args.includes('.gitmodules') && cloned + ? 'submodule.lib.path vendor/lib\n' + : '', + stderr: '' + }) + ) + gitSpawnMock.mockImplementationOnce(() => { + const proc = createMockCloneProcess() + queueMicrotask(() => { + cloned = true + proc.emit('close', 0, null) + }) + return proc + }) + + await expect(listSubmodulePaths(clonePath)).resolves.toEqual([]) + await handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await expect(listSubmodulePaths(clonePath)).resolves.toEqual(['vendor/lib']) + + const configReads = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(configReads).toHaveLength(2) + }) + it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 128b77a66fc..721706fb77f 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -96,6 +96,7 @@ import { } from '../project-groups/folder-workspace-path-status' import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' +import { runWithGitReadCacheInvalidation } from '../git/status' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -982,7 +983,7 @@ async function runWithClonePathLock(clonePathKey: string, task: () => Promise try { await previous - return await task() + return await runWithGitReadCacheInvalidation(task) } finally { release() if (cloneInFlightByPath.get(clonePathKey) === tail) { diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e2805fd2862..3b9a8719afc 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -48,7 +48,12 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', - (): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => { + (): { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' + cols: number + rows: number + }[] => { const overrides = runtime.getAllTerminalFitOverrides() return Array.from(overrides.entries()).map(([ptyId, override]) => ({ ptyId, diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 0c5a51d9ae2..51801ecdef5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -29,7 +29,12 @@ const { addTarget: vi.fn(), updateTarget: vi.fn(), removeTarget: vi.fn(), - importFromSshConfig: vi.fn().mockReturnValue([]) + importFromSshConfig: vi.fn().mockReturnValue([]), + lastRepoReadoptions: [] as { + oldTargetId: string + newTargetId: string + repoIds: string[] + }[] }, mockConnectionManager: { connect: vi.fn(), @@ -293,6 +298,7 @@ describe('SSH IPC handlers', () => { mockSshStore.updateTarget.mockReset() mockSshStore.removeTarget.mockReset() mockSshStore.importFromSshConfig.mockReset().mockReturnValue([]) + mockSshStore.lastRepoReadoptions = [] mockWindow.webContents.send.mockReset() mockStore.getSshRemotePtyLeases.mockReset().mockReturnValue([]) mockStore.markSshRemotePtyLease.mockReset() @@ -379,7 +385,28 @@ describe('SSH IPC handlers', () => { const result = await handlers.get('ssh:addTarget')!(null, { target: newTarget }) expect(mockSshStore.addTarget).toHaveBeenCalledWith(newTarget) - expect(result).toEqual(withId) + expect(result).toEqual({ target: withId, repoReadoptions: [] }) + }) + + it('ssh:addTarget returns exact re-adoption evidence and refreshes repos', async () => { + const target = { + id: 'ssh-new', + label: 'Server', + host: 'server.example.com', + port: 22, + username: 'deploy' + } + const repoReadoptions = [ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ] + mockSshStore.addTarget.mockReturnValue(target) + mockSshStore.lastRepoReadoptions = repoReadoptions + + const result = await handlers.get('ssh:addTarget')!(null, { target }) + + expect(result).toEqual({ target, repoReadoptions }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + expect(mockSshStore.lastRepoReadoptions).toEqual([]) }) it('ssh:removeTarget calls store.removeTarget', async () => { @@ -438,7 +465,7 @@ describe('SSH IPC handlers', () => { mockSshStore.importFromSshConfig.mockReturnValue(imported) const result = await handlers.get('ssh:importConfig')!(null, {}) - expect(result).toEqual(imported) + expect(result).toEqual({ targets: imported, repoReadoptions: [] }) }) it('ssh:connect throws for unknown targetId', async () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 8b5756bff38..affb1aba004 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -11,6 +11,7 @@ import type { DetectedPort, EnrichedDetectedPort, SavedPortForward, + SshRepoReadoption, SshTarget, SshConnectionStatus, SshConnectionState @@ -744,15 +745,17 @@ export function registerSshHandlers( // Why: SSH target add/import can re-adopt workspaces orphaned on a removed // target id (see ssh-target-readoption). When that re-points repos, the // renderer must refresh its repo list to surface the reattached workspaces. - function notifyReposChangedIfReadopted(): void { - if (!sshStore || sshStore.lastReadoptedRepoCount <= 0) { - return + function takeRepoReadoptions(): SshRepoReadoption[] { + if (!sshStore || sshStore.lastRepoReadoptions.length === 0) { + return [] } - sshStore.lastReadoptedRepoCount = 0 + const repoReadoptions = sshStore.lastRepoReadoptions + sshStore.lastRepoReadoptions = [] const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('repos:changed') } + return repoReadoptions } ipcMain.handle('ssh:listTargets', () => { @@ -768,8 +771,8 @@ export function registerSshHandlers( // Why: re-adding a removed host can re-adopt orphaned workspaces (re-point // repos/worktrees off the dead id). Refresh the renderer's repo list so the // reattached workspaces move from grey ghosts back onto the live host. - notifyReposChangedIfReadopted() - return target + const repoReadoptions = takeRepoReadoptions() + return { target, repoReadoptions } }) ipcMain.handle( @@ -784,9 +787,9 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { - const result = sshStore!.importFromSshConfig(args) - notifyReposChangedIfReadopted() - return result + const targets = sshStore!.importFromSshConfig(args) + const repoReadoptions = takeRepoReadoptions() + return { targets, repoReadoptions } }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts index 4435c1c77e3..806d0fa5ab3 100644 --- a/src/main/ipc/ui.test.ts +++ b/src/main/ipc/ui.test.ts @@ -1,13 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { fromWebContentsMock, getAllWindowsMock, handleMock, onMock, removeAllListenersMock } = - vi.hoisted(() => ({ - fromWebContentsMock: vi.fn(), - getAllWindowsMock: vi.fn(() => []), - handleMock: vi.fn(), - onMock: vi.fn(), - removeAllListenersMock: vi.fn() - })) +const { + fromIdMock, + fromWebContentsMock, + getAllWebContentsMock, + getAllWindowsMock, + handleMock, + onMock, + removeAllListenersMock +} = vi.hoisted(() => ({ + fromIdMock: vi.fn(), + fromWebContentsMock: vi.fn(), + getAllWebContentsMock: vi.fn(), + getAllWindowsMock: vi.fn(() => []), + handleMock: vi.fn(), + onMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) vi.mock('electron', () => ({ BrowserWindow: { @@ -18,12 +27,17 @@ vi.mock('electron', () => ({ handle: handleMock, on: onMock, removeAllListeners: removeAllListenersMock + }, + webContents: { + fromId: fromIdMock, + getAllWebContents: getAllWebContentsMock } })) import { clearTrustedUIRendererWebContentsId, registerUIHandlers, + sendToTrustedUIRenderer, setTrustedUIRendererWebContentsId } from './ui' @@ -56,10 +70,13 @@ function getNativePasteHandler(): return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1] } -describe('registerUIHandlers', () => { +describe('UI IPC', () => { beforeEach(() => { vi.stubEnv('ELECTRON_RENDERER_URL', '') + fromIdMock.mockReset() fromWebContentsMock.mockReset() + getAllWebContentsMock.mockReset() + getAllWebContentsMock.mockReturnValue([]) getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) handleMock.mockReset() @@ -72,6 +89,69 @@ describe('registerUIHandlers', () => { vi.unstubAllEnvs() }) + it('sends app events once to the trusted renderer without waking 100 browser guests', () => { + const rendererSend = vi.fn() + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) + fromIdMock.mockReturnValue({ id: 17, isDestroyed: () => false, send: rendererSend }) + setTrustedUIRendererWebContentsId(17) + + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 1 }) + + expect(fromIdMock).toHaveBeenCalledOnce() + expect(fromIdMock).toHaveBeenCalledWith(17) + expect(rendererSend).toHaveBeenCalledOnce() + expect(rendererSend).toHaveBeenCalledWith('gh:prRefreshEvent', { sequence: 1 }) + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) + }) + + it('skips missing, destroyed, and originating renderers', () => { + const rendererSend = vi.fn() + setTrustedUIRendererWebContentsId(17) + + fromIdMock.mockReturnValueOnce(undefined) + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }) + + fromIdMock.mockReturnValueOnce({ id: 17, isDestroyed: () => true, send: rendererSend }) + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }) + + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }, 17) + + expect(fromIdMock).toHaveBeenCalledTimes(2) + expect(rendererSend).not.toHaveBeenCalled() + }) + + it('routes to a reopened window without retaining the closed renderer', () => { + const oldRendererSend = vi.fn() + const newRendererSend = vi.fn() + fromIdMock.mockImplementation((id) => + id === 17 + ? { id, isDestroyed: () => false, send: oldRendererSend } + : { id, isDestroyed: () => false, send: newRendererSend } + ) + + setTrustedUIRendererWebContentsId(17) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 1 }) + + setTrustedUIRendererWebContentsId(42) + clearTrustedUIRendererWebContentsId(17) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 2 }) + + clearTrustedUIRendererWebContentsId(42) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 3 }) + + expect(oldRendererSend).toHaveBeenCalledTimes(1) + expect(newRendererSend).toHaveBeenCalledTimes(1) + expect(newRendererSend).toHaveBeenCalledWith('gh:prRefreshEvent', { sequence: 2 }) + }) + it('routes native paste fallback to the requesting webContents only', () => { const paste = vi.fn() const pasteAndMatchStyle = vi.fn() diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index efe6e300f85..48c26308e3f 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain, type WebContents } from 'electron' +import { BrowserWindow, ipcMain, webContents, type WebContents } from 'electron' import type { Store } from '../persistence' import type { PersistedUIState } from '../../shared/types' import { isFeatureInteractionId } from '../../shared/feature-interactions' @@ -15,6 +15,24 @@ export function clearTrustedUIRendererWebContentsId(webContentsId: number): void } } +export function sendToTrustedUIRenderer( + channel: string, + payload: unknown, + excludedWebContentsId?: number +): void { + const rendererId = trustedUIRendererWebContentsId + if (rendererId == null || rendererId === excludedWebContentsId) { + return + } + const renderer = webContents.fromId(rendererId) + if (!renderer || renderer.isDestroyed()) { + return + } + // Why: app events belong to the registered UI renderer; enumerating every + // WebContents wakes retained browser guests that cannot consume the channel. + renderer.send(channel, payload) +} + export function registerUIHandlers(store: Store): void { // Why: UI view-state is shared between the desktop renderer and mobile (ui.set // RPC). Broadcast every change so the desktop re-hydrates when mobile (or diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index 509e46e0729..1283333ee95 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -20,6 +20,7 @@ import { getWorktreePathSettings, hasRepoWorktreeBasePath } from './worktree-logic' +import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory' import type { WorktreeBaseRepoWatchConfig, @@ -130,8 +131,7 @@ async function maybeAddBaseTarget( // Why: WSL UNC roots are unreliable for native watching; avoid project-level polling. if (isWslUncPath(workspaceRoot) || isWslUncPath(repo.path)) { const key = `${repo.id}:${workspaceRoot}` - if (!skippedWslWarnings.has(key)) { - skippedWslWarnings.add(key) + if (shouldEmitBoundedWarning(skippedWslWarnings, key)) { console.warn( `[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}` ) @@ -157,8 +157,7 @@ async function maybeAddBaseTarget( } } catch { const key = normalizeWatchKey(workspaceRoot) - if (!missingRootWarnings.has(key)) { - missingRootWarnings.add(key) + if (shouldEmitBoundedWarning(missingRootWarnings, key)) { console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`) } } diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 503c830a97b..1f52f2fffe0 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,6 +1,6 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path' import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' -import { resolveRuntimePath } from '../../shared/cross-platform-path' +import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' import { getWslHome, parseWslPath } from '../wsl' @@ -10,6 +10,7 @@ type WorktreeBasePathRepo = Pick export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name' export { mergeWorktree } from './worktree-metadata-merge' +export { areWorktreePathsEqual } from './worktree-path-comparison' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -153,65 +154,13 @@ export function hasRepoWorktreeBasePath(repo: Pick): b return getRepoWorktreeBasePath(repo) !== undefined } -export function areWorktreePathsEqual( - leftPath: string, - rightPath: string, - platform = process.platform -): boolean { - if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { - // Why: local WSL projects run POSIX paths on a Windows desktop; comparing - // them with win32 rules can delete or dedupe the wrong runtime-owned path. - if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { - return false - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right - } - - if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { - const left = win32.normalize(win32.resolve(leftPath)) - const right = win32.normalize(win32.resolve(rightPath)) - // Why: `git worktree list` can report the same Windows path with different - // slash styles or drive-letter casing than the path we computed before - // creation. Orca must treat those as the same worktree or a successful - // create spuriously fails until the next full reload repopulates state. - return left.toLowerCase() === right.toLowerCase() - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return ( - /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//') - ) -} - -function looksLikePosixAbsolutePath(pathValue: string): boolean { - return pathValue.startsWith('/') && !pathValue.startsWith('//') -} - -function normalizePosixWorktreePathForComparison( - pathValue: string, - platform: NodeJS.Platform -): string { - const normalized = posix.normalize(posix.resolve(pathValue)) - if (platform !== 'darwin') { - return normalized - } - if (normalized === '/private/tmp') { - return '/tmp' - } - return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized -} - function getRuntimePathOps( repoPath: string, workspaceDir: string ): Pick { - return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix + return isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(workspaceDir) + ? win32 + : posix } function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string { diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts new file mode 100644 index 00000000000..38bb0aef7f9 --- /dev/null +++ b/src/main/ipc/worktree-path-comparison.ts @@ -0,0 +1,108 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export function areWorktreePathsEqual( + leftPath: string, + rightPath: string, + platform = process.platform +): boolean { + if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { + // Why: local WSL projects run POSIX paths on a Windows desktop; comparing + // them with win32 rules can delete or dedupe the wrong runtime-owned path. + if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { + return false + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right + } + + if ( + platform === 'win32' || + isWindowsAbsolutePathLike(leftPath) || + isWindowsAbsolutePathLike(rightPath) + ) { + const left = normalizeWindowsWorktreePathForComparison(leftPath) + const right = normalizeWindowsWorktreePathForComparison(rightPath) + // Why: Git can report the same Windows path with different slash styles or + // drive-letter casing; treating them as distinct creates duplicate worktrees. + return left === right + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right +} + +export function dedupeWorktreesByPath( + worktrees: readonly T[], + platform = process.platform +): T[] { + // Why: large Git/relay listings should normalize each path once while still + // preserving the first row under the cross-platform equality contract above. + const unique: T[] = [] + const posixAbsoluteKeys = new Set() + const windowsKeys = new Set() + const windowsPaths: string[] = [] + const relativePaths: string[] = [] + + for (const worktree of worktrees) { + const pathValue = worktree.path + if (looksLikePosixAbsolutePath(pathValue)) { + const key = normalizePosixWorktreePathForComparison(pathValue, platform) + if (posixAbsoluteKeys.has(key)) { + continue + } + posixAbsoluteKeys.add(key) + unique.push(worktree) + continue + } + + const windowsKey = normalizeWindowsWorktreePathForComparison(pathValue) + if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { + if ( + windowsKeys.has(windowsKey) || + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + windowsKeys.add(windowsKey) + windowsPaths.push(pathValue) + unique.push(worktree) + continue + } + + // Why: Git normally reports absolute paths. Retain pair-aware comparison + // only for malformed/legacy relative rows whose flavor depends on its peer. + if ( + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) || + windowsPaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + relativePaths.push(pathValue) + unique.push(worktree) + } + return unique +} + +function looksLikePosixAbsolutePath(pathValue: string): boolean { + return pathValue.startsWith('/') && !pathValue.startsWith('//') +} + +function normalizeWindowsWorktreePathForComparison(pathValue: string): string { + return win32.normalize(win32.resolve(pathValue)).toLowerCase() +} + +function normalizePosixWorktreePathForComparison( + pathValue: string, + platform: NodeJS.Platform +): string { + const normalized = posix.normalize(posix.resolve(pathValue)) + if (platform !== 'darwin') { + return normalized + } + if (normalized === '/private/tmp') { + return '/tmp' + } + return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized +} diff --git a/src/main/ipc/worktree-path-deduplication.test.ts b/src/main/ipc/worktree-path-deduplication.test.ts new file mode 100644 index 00000000000..70ebbbc4004 --- /dev/null +++ b/src/main/ipc/worktree-path-deduplication.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './worktree-path-comparison' + +function dedupeWithComparator( + worktrees: readonly T[], + platform: NodeJS.Platform +): T[] { + const unique: T[] = [] + for (const worktree of worktrees) { + if (!unique.some((existing) => areWorktreePathsEqual(existing.path, worktree.path, platform))) { + unique.push(worktree) + } + } + return unique +} + +describe('dedupeWorktreesByPath', () => { + it.each(['darwin', 'linux', 'win32'])( + 'preserves the comparator result on %s', + (platform) => { + const worktrees = [ + { id: 'posix-upper', path: '/home/dev/Repo' }, + { id: 'posix-lower', path: '/home/dev/repo' }, + { id: 'windows-first', path: 'C:\\Workspaces\\Feature' }, + { id: 'windows-duplicate', path: 'c:/workspaces/feature' }, + { id: 'unc-first', path: '\\\\Server\\Share\\Feature' }, + { id: 'unc-duplicate', path: '//server/share/feature' }, + { id: 'temp-private', path: '/private/tmp/orca/feature' }, + { id: 'temp-short', path: '/tmp/orca/feature' }, + { id: 'relative-first', path: 'workspaces/feature' }, + { id: 'relative-duplicate', path: 'workspaces/./feature' } + ] + + for (let offset = 0; offset < worktrees.length; offset += 1) { + const rotated = [...worktrees.slice(offset), ...worktrees.slice(0, offset)] + expect(dedupeWorktreesByPath(rotated, platform)).toEqual( + dedupeWithComparator(rotated, platform) + ) + const reversed = rotated.toReversed() + expect(dedupeWorktreesByPath(reversed, platform)).toEqual( + dedupeWithComparator(reversed, platform) + ) + } + } + ) + + it('reads each path once for a large unique list', () => { + let pathReads = 0 + const worktrees = Array.from({ length: 1_000 }, (_, index) => ({ + get path(): string { + pathReads += 1 + return `/workspaces/feature-${index}` + } + })) + + expect(dedupeWorktreesByPath(worktrees)).toHaveLength(1_000) + expect(pathReads).toBe(1_000) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 43ef357eed6..6a5db1be3de 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -27,7 +27,11 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' +import { + getBranchConflictKind, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from '../git/repo' import { resolveLocalGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' @@ -1395,7 +1399,8 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate( async function evaluateRemoteLocalBaseRefRefreshability( provider: SshGitProvider, repoPath: string, - remoteTrackingBase: RemoteTrackingBase + remoteTrackingBase: RemoteTrackingBase, + shouldInspectOwner: (behind: number) => boolean = () => true ): Promise { const resultBase = { baseRef: remoteTrackingBase.base, @@ -1413,6 +1418,17 @@ async function evaluateRemoteLocalBaseRefRefreshability( repoPath ) behind = countNonEmptyGitOutputLines(stdout) + if (!shouldInspectOwner(behind)) { + // Why: no behind commits means the advisory cannot offer an update; + // avoid remote worktree/status round trips that cannot change that. + return { + refreshable: true, + ...resultBase, + fullRef, + remoteTrackingRef: remoteTrackingBase.ref, + behind + } + } } catch { return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } @@ -1467,7 +1483,8 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( const evaluation = await evaluateRemoteLocalBaseRefRefreshability( provider, repoPath, - remoteTrackingBase + remoteTrackingBase, + (behind) => behind > 0 ) if (!evaluation.refreshable || evaluation.behind <= 0) { return undefined @@ -1536,9 +1553,12 @@ export async function createRemoteWorktree( // Register the repo root first so relays do not report a valid base as stale. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) - // Why: SSH targets cannot use the local `gh` account, and git email/name are - // commit author identity rather than hosted-account usernames. - const username = await getSshGitUsername(provider, repo.path) + // Why: explicit branches and non-username prefix modes never consume this + // value; skipping the remote config probes preserves the exact branch name. + const username = + !args.branchNameOverride && settings.branchPrefix === 'git-username' + ? await getSshGitUsername(provider, repo.path) + : '' const branchConflictSubject = args.branchNameOverride ? 'branch name' : 'worktree name' // Determine base branch @@ -1883,8 +1903,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. @@ -1968,18 +1988,22 @@ export async function createLocalWorktree( return { ...options, ...localWorktreeGitOptions } } - const username = await resolveLocalGitUsername(repo.path) const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) const requestedDisplayName = args.displayName ? sanitizeWorktreeDisplayName(args.displayName) : undefined + // Why: explicit branches and non-username prefix modes never consume this + // value; skipping the probes preserves the exact generated branch name. + const username = + !args.branchNameOverride && settings.branchPrefix === 'git-username' + ? await resolveLocalGitUsername(repo.path) + : '' const baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: () => - resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)), + resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), isBaseUsable: async (baseBranchCandidate) => { if (runtime) { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( @@ -2512,10 +2536,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-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 3ea68e4ba1b..73d10495b1f 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -10,6 +10,7 @@ const { removeWorktreeMock, resolveLocalGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getBranchConflictKindMock, getPRForBranchMock, @@ -38,6 +39,7 @@ const { removeWorktreeMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), @@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ getDefaultBaseRef: getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getBranchConflictKind: getBranchConflictKindMock })) @@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { removeWorktreeMock.mockReset() resolveLocalGitUsernameMock.mockReset() getDefaultBaseRefMock.mockReset() + resolveDefaultBaseRefWithLocalGitMock.mockReset() resolveDefaultBaseRefViaExecMock.mockReset() getBranchConflictKindMock.mockReset() getPRForBranchMock.mockReset() @@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) @@ -287,6 +292,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { 'origin/main', false ) + expect(resolveLocalGitUsernameMock).not.toHaveBeenCalled() expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::C:/workspaces/improve-dashboard', expect.objectContaining({ @@ -302,6 +308,39 @@ describe('registerWorktreeHandlers – Windows path handling', () => { }) }) + it('resolves the Git username when the configured prefix consumes it', async () => { + store.getSettings.mockReturnValue({ + branchPrefix: 'git-username', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: 'C:\\workspaces' + }) + resolveLocalGitUsernameMock.mockResolvedValue('octocat') + listWorktreesMock.mockResolvedValue([ + { + path: 'C:/workspaces/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/octocat/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + + expect(resolveLocalGitUsernameMock).toHaveBeenCalledWith('C:\\repo') + expect(addWorktreeMock).toHaveBeenCalledWith( + 'C:\\repo', + 'C:\\workspaces\\improve-dashboard', + 'octocat/improve-dashboard', + 'origin/main', + false + ) + }) + it('preserves create-time metadata on the next list when Windows path formatting differs', async () => { const worktreeEntry = { path: 'C:/workspaces/improve-dashboard', diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8e128912517..031f9cf703d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -28,7 +28,8 @@ const { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -77,7 +78,8 @@ const { removeWorktreeMock: vi.fn(), forceDeleteLocalBranchMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), - getDefaultBaseRefMock: vi.fn(), + getBaseRefDefaultMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getDefaultRemoteMock: vi.fn(), getBranchConflictKindMock: vi.fn(), @@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../git/repo', () => ({ - getDefaultBaseRef: getDefaultBaseRefMock, + getBaseRefDefault: getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, getBranchConflictKind: getBranchConflictKindMock @@ -243,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> @@ -282,6 +289,7 @@ describe('registerWorktreeHandlers', () => { resolveManagedMrBase: ReturnType createTerminal: ReturnType splitTerminal: ReturnType + notifyWorktreesChangedForRemoteClients: ReturnType } beforeEach(() => { @@ -299,7 +307,8 @@ describe('registerWorktreeHandlers', () => { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -400,7 +409,8 @@ describe('registerWorktreeHandlers', () => { ]) store.getAllWorktreeLineage.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') - getDefaultBaseRefMock.mockReturnValue('origin/main') + getBaseRefDefaultMock.mockResolvedValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') getBranchConflictKindMock.mockResolvedValue(null) @@ -490,7 +500,8 @@ describe('registerWorktreeHandlers', () => { handle: 'term-setup', tabId: 'tab-startup', paneRuntimeId: -1 - }) + }), + notifyWorktreesChangedForRemoteClients: vi.fn() } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -523,6 +534,7 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo') expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( '/workspace/repo', 'origin/master' @@ -742,6 +754,24 @@ describe('registerWorktreeHandlers', () => { expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('pushes a remote-client invalidation for renames but not read-state updates', () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { isUnread: false } + }) + // Why: per-click isUnread writes must stay event-free (PR #209), while a + // rename must reach paired remote clients that no longer poll for titles. + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled() + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { displayName: 'Renamed workspace' } + }) + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') + }) + it('does not trust renderer-authored automation provenance during local create', async () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) listWorktreesMock.mockResolvedValue([ @@ -888,6 +918,13 @@ describe('registerWorktreeHandlers', () => { }) it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { + store.getSettings.mockReturnValue({ + branchPrefix: 'git-username', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace' + }) + resolveLocalGitUsernameMock.mockResolvedValue('unused-user') listWorktreesMock.mockResolvedValue([ { path: '/workspace/feature-something', @@ -915,6 +952,7 @@ describe('registerWorktreeHandlers', () => { 'origin/main', false ) + expect(resolveLocalGitUsernameMock).not.toHaveBeenCalled() expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/feature-something', @@ -2022,12 +2060,6 @@ describe('registerWorktreeHandlers', () => { it('routes local worktree creation through the selected WSL project runtime', async () => { mockSelectedWslProjectRuntime() - resolveDefaultBaseRefViaExecMock.mockImplementation( - async (exec: (args: string[]) => Promise<{ stdout: string }>) => { - await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - return 'origin/main' - } - ) listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', @@ -2059,10 +2091,10 @@ describe('registerWorktreeHandlers', () => { false, { wslDistro: 'Ubuntu' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } - ) + expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({ + cwd: '/workspace/repo', + wslDistro: 'Ubuntu' + }) expect(getBranchConflictKindMock).toHaveBeenCalledWith( '/workspace/repo', 'improve-dashboard', @@ -2605,6 +2637,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[]) => { @@ -2789,6 +3014,13 @@ describe('registerWorktreeHandlers', () => { fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'base123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, { path: '/remote/improve-dashboard', head: 'abc123', @@ -2796,7 +3028,8 @@ describe('registerWorktreeHandlers', () => { isBare: false, isMainWorktree: false } - ]) + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) } const mux = { request: vi.fn().mockResolvedValue(undefined), @@ -2818,6 +3051,16 @@ describe('registerWorktreeHandlers', () => { manualOrder: 123_456 }) + expect(provider.exec).not.toHaveBeenCalledWith( + ['config', '--get', 'github.user'], + '/remote/repo' + ) + expect(provider.exec).not.toHaveBeenCalledWith( + ['config', '--get', 'user.username'], + '/remote/repo' + ) + expect(provider.listWorktrees).toHaveBeenCalledTimes(1) + expect(provider.worktreeIsClean).not.toHaveBeenCalled() expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-ssh::/remote/improve-dashboard', expect.objectContaining({ @@ -3778,18 +4021,15 @@ describe('registerWorktreeHandlers', () => { }), fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - path: '/remote/improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) + listWorktrees: vi.fn().mockResolvedValueOnce([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) } const mux = { request: vi.fn().mockResolvedValue(undefined), @@ -3999,7 +4239,6 @@ describe('registerWorktreeHandlers', () => { addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi .fn() - .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { path: '/remote/first-worktree', @@ -4009,7 +4248,6 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { path: '/remote/second-worktree', @@ -4939,7 +5177,7 @@ describe('registerWorktreeHandlers', () => { // no origin/main, no origin/master, and no local main/master), we must // fail loudly with a message that prompts the user to pick a base // branch, not hand a non-existent ref to `git worktree add`. - resolveDefaultBaseRefViaExecMock.mockResolvedValue(null) + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null) store.getRepo.mockReturnValue({ id: 'repo-1', path: '/workspace/repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 67468a78b06..6aeb1144313 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -72,6 +72,7 @@ import { isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from './worktree-logic' +import { dedupeWorktreesByPath } from './worktree-path-comparison' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { createLocalWorktree, @@ -99,6 +100,7 @@ import { releaseAutomationWorkspaceProvenanceRequest, resolveAutomationWorkspaceProvenance } from '../automations/workspace-provenance' +import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -149,7 +151,10 @@ import { } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId +} from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { getLocalProjectGitExecOptions, @@ -208,19 +213,6 @@ async function closeLocalWatcherForRemoval(worktreePath: string): Promise }) } -function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeInfo[] { - const uniqueGitWorktrees: GitWorktreeInfo[] = [] - for (const gitWorktree of gitWorktrees) { - if ( - uniqueGitWorktrees.some((existing) => areWorktreePathsEqual(existing.path, gitWorktree.path)) - ) { - continue - } - uniqueGitWorktrees.push(gitWorktree) - } - return uniqueGitWorktrees -} - function getProjectHostSetupMetaUpdates( store: Store, repo: Repo, @@ -477,37 +469,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( @@ -530,24 +544,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) @@ -563,10 +578,9 @@ function getDetectedWorktreeScanCacheKey( } function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { - if (keySet.has(key)) { + if (!shouldEmitBoundedWarning(keySet, key)) { return } - keySet.add(key) if (error) { console.warn(message, error) } else { @@ -718,7 +732,7 @@ function buildDetectedGitWorktrees( const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - return dedupeGitWorktreesByPath(gitWorktrees).map((gitWorktree) => { + return dedupeWorktreesByPath(gitWorktrees).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -2029,6 +2043,14 @@ export function registerWorktreeHandlers( // sortEpoch and reorders the sidebar — the exact bug PR #209 tried // to fix (clicking a card would clear isUnread → updateMeta → // worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort). + if (args.updates.displayName !== undefined) { + // Why: paired remote clients have no optimistic copy of the rename and + // no longer poll for titles, so push the remote-only invalidation. + // Gated on displayName to keep isUnread-per-click updates event-free. + // getRepoIdFromWorktreeId matches the mobile client's event filter and + // never throws after the meta write already succeeded. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } return meta } ) diff --git a/src/main/observability/tracer.test.ts b/src/main/observability/tracer.test.ts index d1bec74ab98..d2859436138 100644 --- a/src/main/observability/tracer.test.ts +++ b/src/main/observability/tracer.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { _resetTracerForTests, + flushActiveSink, getActiveSpanContext, setActiveSink, startSpan, @@ -69,6 +70,23 @@ describe('tracer — basic span lifecycle', () => { expect(r.exit.cause).toBe('user-cancelled') }) + it('keeps forced sink flush failures from escaping crash boundaries', () => { + sink.flush = () => { + throw new Error('disk unavailable') + } + + expect(() => flushActiveSink()).not.toThrow() + }) + + it('keeps sink handoff failures from escaping instrumented operations', async () => { + sink.push = () => { + throw new Error('trace rotation failed') + } + + expect(() => startSpan('test').fail('boom')).not.toThrow() + await expect(withSpan('async-test', async () => 'result')).resolves.toBe('result') + }) + it('end() is idempotent — second call is a no-op', () => { const span = startSpan('test') span.end() diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts index 3ef93449cf0..c151aa6a2fb 100644 --- a/src/main/observability/tracer.ts +++ b/src/main/observability/tracer.ts @@ -112,6 +112,17 @@ export function setActiveSink(sink: TracerSink | null): void { activeSink = sink } +/** Force records already handed to the tracer onto disk. Reserve this for + * crash boundaries where the process may not survive the normal batch window. */ +export function flushActiveSink(): void { + try { + activeSink?.flush() + } catch { + // Why: a disk/rotation failure in optional local diagnostics must never + // turn a recoverable renderer crash into a main-process crash. + } +} + /** Get the current parent context, or `undefined` if we are at the top of * the trace tree. Renderer-IPC entry points capture this, embed it in * span-event attributes (so cross-process spans can be visually linked @@ -221,7 +232,12 @@ export function startSpan( // Wrap in a `type: 'effect-span'` envelope so the NDJSON file is // compatible with Effect-style span output. Effect-oriented consumers // (the LGTM stack, jq cookbooks) can read the file unchanged. - activeSink?.push({ type: 'effect-span', ...redacted }) + try { + activeSink?.push({ type: 'effect-span', ...redacted }) + } catch { + // Why: tracing is optional; a local file rotation failure must not turn + // the instrumented operation into an application failure. + } } return { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a0d3f760779..4ae746a1f28 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -562,6 +562,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 () => { @@ -3317,9 +3361,9 @@ describe('Store', () => { store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') expect(repo.executionHostId).toBe('ssh:ssh-new') @@ -3338,9 +3382,9 @@ describe('Store', () => { }) ) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['ssh-repo']) expect(store.getRepo('local-repo')!.connectionId).toBeUndefined() expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new') }) @@ -3350,9 +3394,9 @@ describe('Store', () => { // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') // Must not stamp an executionHostId where there wasn't one. @@ -3365,8 +3409,8 @@ describe('Store', () => { // must still be saved, not left in memory only. store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(0) // no repo matched + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') + expect(repoIds).toEqual([]) // no repo matched store.flush() const reloaded = await createStore() @@ -6017,6 +6061,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, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d4b68e7f4b4..44d0827bc5b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -104,6 +104,9 @@ 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 { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -2996,6 +2999,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 @@ -3135,6 +3143,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 +3322,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 +3354,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated @@ -5333,6 +5370,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 +5452,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 ), @@ -6141,16 +6184,16 @@ export class Store { /** * Re-point every repo and worktree meta pinned to a removed SSH target id * onto a re-added target's id, so orphaned workspaces reattach to the live - * host instead of remaining un-removable ghosts. Returns the number of repos - * re-pointed (0 when nothing referenced the old id). + * host instead of remaining un-removable ghosts. Returns the ids of repos + * re-pointed (empty when nothing referenced the old id). */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): number { + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { if (oldTargetId === newTargetId) { - return 0 + return [] } const oldHostId = toSshExecutionHostId(oldTargetId) const newHostId = toSshExecutionHostId(newTargetId) - let repoCount = 0 + const repoIds = new Set() for (const repo of this.state.repos) { const matchesConnection = repo.connectionId === oldTargetId const matchesHost = repo.executionHostId === oldHostId @@ -6167,7 +6210,7 @@ export class Store { if (matchesHost) { repo.executionHostId = newHostId } - repoCount++ + repoIds.add(repo.id) } // Re-point worktree metas whose hostId pointed at the old SSH host. let metaChanged = false @@ -6238,13 +6281,13 @@ export class Store { // Why: repo-row and host-setup rewrites can affect host-setup compatibility, // but meta-only rewrites cannot — keep that sync under this gate. Persist // whenever anything changed, so partial re-points aren't lost on quit. - if (repoCount > 0 || setupsChanged) { + if (repoIds.size > 0 || setupsChanged) { this.syncProjectHostSetupCompatibilityState() } - if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) { + if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { this.scheduleSave() } - return repoCount + return [...repoIds] } // ── SSH Remote PTY Leases ────────────────────────────────────────── 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/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index c21d501b49a..a93735a0287 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -9,7 +9,10 @@ vi.mock('child_process', () => ({ })) import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +import { + resolveAgentForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' // Why: the module wraps execFile with promisify, so the mock must honor the @@ -106,6 +109,28 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') }) + it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { + mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + }) + + it('treats a failed fresh POSIX scan as unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') + }) + it('does not report Claude print-mode hook descendants as foreground agents', async () => { mockPs( [ @@ -270,6 +295,117 @@ describe('resolveAgentForegroundProcess', () => { ) }) + it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('enumeration unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + + it.each([ + ['blank', ' \r\n'], + ['unparseable', 'wmic returned no structured process values'] + ])('treats successful but %s WMIC output as unavailable', async (_label, wmicOutput) => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + if (cmd === 'powershell.exe') { + callback(new Error('powershell unavailable'), { stdout: '', stderr: '' }) + return + } + callback(null, { stdout: wmicOutput, stderr: '' }) + }) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + + it('treats an observed Windows shell with no children as authoritative', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]), + stderr: '' + }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('does not restore a recognized fallback that disappeared before confirmation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'droid', { + fresh: true, + forceProcessScan: true + }) + ).resolves.toEqual({ available: true, processName: null }) + }) + + it('treats a Windows snapshot missing the requested shell as unavailable', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'unrelated.exe', + Name: 'unrelated.exe', + ParentProcessId: 99, + ProcessId: 200 + } + ]), + stderr: '' + }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) execFileMock.mockImplementation( @@ -334,6 +470,39 @@ describe('resolveAgentForegroundProcess', () => { ) }) + it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'agy', + Name: 'agy.exe', + ParentProcessId: 100, + ProcessId: 102 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toEqual({ available: true, processName: 'droid' }) + }) + it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) execFileMock.mockImplementation( @@ -494,4 +663,83 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe') expect(execFileMock).not.toHaveBeenCalled() }) + + it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + } + ]) + ) + const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101, 999])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'droid' }) + expect(readWindowsConptyProcessIds).toHaveBeenCalledTimes(1) + }) + + it('excludes a detached Windows Droid descendant from byte authority', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds: async () => new Set([100, 999]) + }) + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]) + ) + const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 999])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + expect(readWindowsConptyProcessIds).not.toHaveBeenCalled() + }) }) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index d60582964bd..6224b804953 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,13 +1,22 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot' import { - resolveWindowsAgentForegroundProcess, + getFreshProcessTableSnapshot, + getProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import { + resolveWindowsAgentForegroundProcessWithAvailability, shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' +export type AgentForegroundProcessResolution = { + available: boolean + processName: string | null +} + function collectDescendants( rows: Row[], rootPid: number @@ -43,29 +52,57 @@ export async function resolveAgentForegroundProcess( fallbackProcess: string | null, options: AgentForegroundResolutionOptions = {} ): Promise { + return (await resolveAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, options)) + .processName +} + +export async function resolveAgentForegroundProcessWithAvailability( + shellPid: number | null | undefined, + fallbackProcess: string | null, + options: AgentForegroundResolutionOptions = {} +): Promise { if (!shellPid) { - return fallbackProcess + return { available: false, processName: fallbackProcess } } if (process.platform === 'win32') { - if (!fallbackProcess || !shouldInspectWindowsAgentForeground(fallbackProcess)) { - return fallbackProcess + if ( + !fallbackProcess || + (!shouldInspectWindowsAgentForeground(fallbackProcess) && !options.forceProcessScan) + ) { + return { available: true, processName: fallbackProcess } } - return ( - (await resolveWindowsAgentForegroundProcess(shellPid, fallbackProcess, options)) ?? - fallbackProcess + const resolution = await resolveWindowsAgentForegroundProcessWithAvailability( + shellPid, + fallbackProcess, + options ) + return { + available: resolution.available, + // Why: a forced confirmation scan that no longer sees the recognized + // fallback is authoritative evidence that the agent exited meanwhile. + processName: + resolution.processName ?? + (options.forceProcessScan && recognizeAgentProcessFromCommandLine(fallbackProcess) + ? null + : fallbackProcess) + } } try { - const rows = await getProcessTableSnapshot() - return resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + const rows = options.fresh + ? await getFreshProcessTableSnapshot() + : await getProcessTableSnapshot() + if (options.fresh && !rows.some((row) => row.pid === shellPid)) { + return { available: false, processName: fallbackProcess } + } + return { + available: true, + processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + } } catch { - // Fall through to node-pty's process name. Foreground process inspection is - // best-effort because terminal identity should never break PTY operation. + return { available: !options.fresh, processName: fallbackProcess } } - - return fallbackProcess } function resolveAgentForegroundProcessFromPs( diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 1448f59d946..66f68d6e868 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -58,7 +58,10 @@ vi.mock('./windows-powershell-executable', () => ({ })) vi.mock('./agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ + available: true, + processName: await resolveAgentForegroundProcessMock(...args) + }) })) vi.mock('../wsl', () => ({ @@ -672,7 +675,8 @@ describe('LocalPtyProvider', () => { await provider.spawn({ cols: 80, rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' } }) } finally { if (savedCodexHome === undefined) { @@ -690,8 +694,12 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') - expect(spawnCall[2].env.WSLENV).toBe( - 'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' + expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( + expect.arrayContaining([ + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) @@ -1039,6 +1047,24 @@ describe('LocalPtyProvider', () => { }) }) + describe('confirmForegroundProcess', () => { + it('drops a delayed result after the PTY exits', async () => { + let resolveScan!: (processName: string) => void + resolveAgentForegroundProcessMock.mockReturnValue( + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const confirmation = provider.confirmForegroundProcess(id) + exitCb?.({ exitCode: 0 }) + resolveScan('droid') + + await expect(confirmation).resolves.toBeNull() + }) + }) + describe('event listeners', () => { it('notifies data listeners when PTY produces output', async () => { const dataHandler = vi.fn() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 941fe062997..90ccae11ea6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -51,11 +51,13 @@ import { resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -566,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider { // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) } + if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore @@ -971,13 +978,45 @@ export class LocalPtyProvider implements IPtyProvider { return null } try { - return await resolveAgentForegroundProcess( + const resolution = await resolveAgentForegroundProcessWithAvailability( proc.pid, resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), { contextPaths: ptyAgentForegroundContextPaths.get(id) } ) + return resolution.processName + } catch { + return null + } + } + + async confirmForegroundProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return null + } + try { + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), + { + contextPaths: ptyAgentForegroundContextPaths.get(id), + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + // Why: a fresh scan can outlive this PTY id; never publish identity from + // an exited process or a replacement session that reused the same id. + if (ptyProcesses.get(id) !== proc) { + return null + } + return resolution.available ? resolution.processName : null } catch { return null } 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.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index d35460bd489..cc3e890c338 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') diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index ce5256d2744..cb95683f9fd 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -2,6 +2,11 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp' +import { + openSshFileUploadSession, + type SftpFactory, + type SshRawTransferOptions +} from './ssh-filesystem-file-upload' import { notifySshFilesystemUnwatch, registerSshFilesystemWatch, @@ -11,14 +16,12 @@ 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 @@ -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 }) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 91c781bf452..eb8383a3f60 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1054,6 +1054,42 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledTimes(3) }) + it.each([ + [ + 'clone', + (provider: SshGitProvider) => + provider.clone(['clone', '--', 'https://example.com/repo.git', 'repo'], '/projects') + ], + [ + 'mutating git.exec', + (provider: SshGitProvider) => + provider.exec(['commit', '--allow-empty', '-m', 'initialize'], '/home/user/repo') + ] + ])('clears pending diff RPCs when %s runs', async (_name, mutate) => { + const diff = { + kind: 'text', + originalContent: 'old', + modifiedContent: 'new', + originalIsBinary: false, + modifiedIsBinary: false + } + const pendingDiff = deferredValue(diff) + mux.request.mockReturnValueOnce(pendingDiff.promise) + + const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) + await waitForRequestCount(mux.request, 1) + + mux.request.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await mutate(provider) + + mux.request.mockResolvedValueOnce(diff) + const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) + + pendingDiff.resolve() + await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) + expect(mux.request).toHaveBeenCalledTimes(3) + }) + it('clears pending branch diff RPCs when a ref-moving provider operation runs', async () => { const diff = { kind: 'text', diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 76fb163909b..4bca707a0fb 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -31,6 +31,7 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' type NonInteractiveExecQueueEntry = { started: boolean @@ -761,9 +762,13 @@ export class SshGitProvider implements IGitProvider { cwd: string, options?: { signal?: AbortSignal; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> { - const result = options - ? await requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) - : await requestGitStreamable(this.mux, 'git.exec', { args, cwd }) + const run = () => + options + ? requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) + : requestGitStreamable(this.mux, 'git.exec', { args, cwd }) + const result = gitExecMutatesRepository(args) + ? await this.runWithDiffDedupeClear(run) + : await run() return result as { stdout: string stderr: string @@ -779,6 +784,7 @@ export class SshGitProvider implements IGitProvider { onProgress?: (progress: { phase: string; percent: number }) => void } ): Promise<{ stdout: string; stderr: string }> { + this.gitDiffReadDedupe.clear() const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}` const unsubscribe = options?.onProgress ? this.mux.onNotificationByMethod('git.cloneProgress', (params) => { @@ -811,6 +817,7 @@ export class SshGitProvider implements IGitProvider { throw error } finally { unsubscribe?.() + this.gitDiffReadDedupe.clear() } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 24bd305bfbc..d5cf5c60985 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -12,6 +12,7 @@ import type { GitStagingArea, GitUpstreamStatus, GitWorktreeInfo, + TuiAgent, RemoveWorktreeResult, SearchOptions, SearchResult @@ -62,6 +63,8 @@ export type PtySpawnOptions = { command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery + /** Minimal allowlisted launch ownership preserved by daemon reattach. */ + launchAgent?: TuiAgent /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string @@ -105,6 +108,8 @@ export type PtySpawnResult = { * local providers read it from node-pty. Null when the underlying * provider could not publish a pid (e.g., race during spawn). */ pid?: number | null + /** Minimal allowlisted launch ownership returned by daemon reattach. */ + launchAgent?: TuiAgent /** ANSI snapshot of the terminal screen, present when reattaching to an * existing daemon session. Write this to xterm.js to restore visual state. */ snapshot?: string @@ -204,6 +209,8 @@ export type IPtyProvider = { acknowledgeDataEvent(id: string, charCount: number): void hasChildProcesses(id: string): Promise getForegroundProcess(id: string): Promise + /** Strong process evidence captured after the caller's command boundary. */ + confirmForegroundProcess?: (id: string) => Promise serialize(ids: string[]): Promise revive(state: string): Promise listProcesses(): Promise @@ -243,6 +250,7 @@ export type IFilesystemProvider = { options: TerminalArtifactAccessOptions ): Promise downloadFile?(sourcePath: string, destinationPath: string): Promise + openFileUploadSession?(): Promise getTempDir?(): Promise writeFile(filePath: string, content: string): Promise writeTerminalArtifact?( @@ -274,6 +282,15 @@ export type IFilesystemProvider = { watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> } +export type FileUploadSession = { + uploadFile( + sourcePath: string, + destinationPath: string, + options?: { exclusive?: boolean } + ): Promise + close(): void +} + export type TerminalArtifactAccessOptions = { expectedRealPath: string expectedStatIdentity: string | null diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 39447432a91..556d6763e62 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -12,6 +12,17 @@ import { export type AgentForegroundResolutionOptions = { contextPaths?: readonly string[] + /** Require a Windows process-table scan started after this request. */ + fresh?: boolean + /** Force confirmation scans even when node-pty reports a recognized name. */ + forceProcessScan?: boolean + /** Lazily proves which global descendants still belong to this ConPTY. */ + readWindowsConptyProcessIds?: () => Promise | null> +} + +export type WindowsAgentForegroundResolution = { + available: boolean + processName: string | null } export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean { @@ -23,21 +34,79 @@ export async function resolveWindowsAgentForegroundProcess( fallbackProcess: string, options: AgentForegroundResolutionOptions ): Promise { - const candidates = await queryWindowsProcessDescendants(shellPid) + return ( + await resolveWindowsAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, options) + ).processName +} + +export async function resolveWindowsAgentForegroundProcessWithAvailability( + shellPid: number, + fallbackProcess: string, + options: AgentForegroundResolutionOptions +): Promise { + const candidates = await queryWindowsProcessDescendants( + shellPid, + options.fresh === true ? { fresh: true } : {} + ) if (!candidates) { - return null + return { available: false, processName: null } } + // Resolve membership before applying the global ambiguity rule. A detached + // agent can otherwise make an attached Droid look ambiguous and suppress + // the only identity that is actually able to receive this PTY's input. + const hasRecognizedCandidate = windowsCandidatesContainRecognizedAgent( + candidates, + fallbackProcess, + options.contextPaths + ) + let filteredCandidates = candidates + if (hasRecognizedCandidate && options.readWindowsConptyProcessIds) { + const conptyProcessIds = await options.readWindowsConptyProcessIds() + if (!conptyProcessIds) { + return { available: false, processName: null } + } + filteredCandidates = candidates.filter((candidate) => conptyProcessIds.has(candidate.pid)) + } + return { + available: true, + processName: resolveWindowsProcessName( + filteredCandidates, + fallbackProcess, + options.contextPaths + ) + } +} + +function windowsCandidatesContainRecognizedAgent( + candidates: readonly WindowsProcessCandidate[], + fallbackProcess: string, + contextPaths: readonly string[] | undefined +): boolean { if (isShellProcess(fallbackProcess)) { - return resolveShellForegroundProcessFromWindowsCandidates(candidates, options.contextPaths) + return createRecognizedWindowsProcessCandidates(candidates, contextPaths).length > 0 + } + return candidates + .filter((candidate) => windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)) + .some( + (candidate) => + recognizeAgentProcessFromCommandLine(candidate.command) !== null || + recognizeAgentProcessFromCommandLine(candidate.name) !== null + ) +} + +function resolveWindowsProcessName( + candidates: readonly WindowsProcessCandidate[], + fallbackProcess: string, + contextPaths: readonly string[] | undefined +): string | null { + if (isShellProcess(fallbackProcess)) { + return resolveShellForegroundProcessFromWindowsCandidates(candidates, contextPaths) } const wrapperCandidates = candidates.filter((candidate) => windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess) ) if (wrapperCandidates.length !== 1) { - return resolveWrapperForegroundProcessFromWindowsCandidates( - wrapperCandidates, - options.contextPaths - ) + return resolveWrapperForegroundProcessFromWindowsCandidates(wrapperCandidates, contextPaths) } const [candidate] = wrapperCandidates const recognized = diff --git a/src/main/providers/windows-conpty-process-membership.test.ts b/src/main/providers/windows-conpty-process-membership.test.ts new file mode 100644 index 00000000000..e9e2ef09fdd --- /dev/null +++ b/src/main/providers/windows-conpty-process-membership.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' + +function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { + const child = new EventEmitter() as EventEmitter & { kill: ReturnType } + child.kill = vi.fn() + const forkProcess = vi.fn(() => { + queueMicrotask(() => { + if (event === 'message') { + child.emit('message', { consoleProcessList: value }) + } else if (event === 'error') { + child.emit('error', new Error('spawn failed')) + } + }) + return child + }) + return { child, forkProcess: forkProcess as never } +} + +describe('readWindowsConptyProcessIds', () => { + it('returns exact console membership from the fixed node-pty helper', async () => { + const { forkProcess } = forkWith('message', [101, 202, 303]) + + await expect( + readWindowsConptyProcessIds(101, { + forkProcess, + resolveAgentPath: () => '/fixed/node-pty/conpty_console_list_agent.js' + }) + ).resolves.toEqual(new Set([101, 202, 303])) + expect(forkProcess).toHaveBeenCalledWith( + '/fixed/node-pty/conpty_console_list_agent.js', + ['101'], + { silent: true } + ) + }) + + it.each([ + ['root-only failure fallback', [101]], + ['malformed response', [101, '202']], + ['missing PTY root', [202, 303]] + ])('fails closed for %s', async (_label, processIds) => { + const { forkProcess } = forkWith('message', processIds) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() + }) + + it('handles helper spawn errors without an unhandled child error', async () => { + const { forkProcess } = forkWith('error') + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() + }) + + it('kills a silent helper at the bounded timeout', async () => { + vi.useFakeTimers() + try { + const { child, forkProcess } = forkWith('none') + const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBeNull() + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/providers/windows-conpty-process-membership.ts b/src/main/providers/windows-conpty-process-membership.ts new file mode 100644 index 00000000000..759a234c63f --- /dev/null +++ b/src/main/providers/windows-conpty-process-membership.ts @@ -0,0 +1,74 @@ +import { fork, type ChildProcess } from 'node:child_process' + +const CONPTY_PROCESS_LIST_TIMEOUT_MS = 3_000 + +type ProcessListMessage = { consoleProcessList?: unknown } + +type WindowsConptyMembershipDeps = { + forkProcess?: typeof fork + resolveAgentPath?: () => string + timeoutMs?: number +} + +function resolveNodePtyConsoleListAgent(): string { + return require.resolve('node-pty/lib/conpty_console_list_agent.js') +} + +/** + * Runs node-pty's fixed native console-list helper with bounded error/exit + * handling. A root-only result is its failure fallback, not shell proof. + */ +export function readWindowsConptyProcessIds( + rootPid: number, + deps: WindowsConptyMembershipDeps = {} +): Promise | null> { + if (!Number.isSafeInteger(rootPid) || rootPid <= 0) { + return Promise.resolve(null) + } + let child: ChildProcess + try { + child = (deps.forkProcess ?? fork)( + (deps.resolveAgentPath ?? resolveNodePtyConsoleListAgent)(), + [String(rootPid)], + { silent: true } + ) + } catch { + return Promise.resolve(null) + } + + return new Promise((resolve) => { + let settled = false + const finish = (value: ReadonlySet | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + child.removeListener('message', onMessage) + child.removeListener('error', onFailure) + child.removeListener('exit', onFailure) + resolve(value) + } + const onFailure = (): void => finish(null) + const onMessage = (message: ProcessListMessage): void => { + const value = message?.consoleProcessList + if ( + !Array.isArray(value) || + value.length <= 1 || + !value.includes(rootPid) || + value.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) + ) { + finish(null) + return + } + finish(new Set(value)) + } + const timeout = setTimeout(() => { + child.kill() + finish(null) + }, deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) + child.once('message', onMessage) + child.once('error', onFailure) + child.once('exit', onFailure) + }) +} diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index d26067122a5..6fea54165c8 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -48,14 +48,23 @@ const windowsProcessRowsReader = createProcessTableSnapshotReader { let rows: WindowsProcessRow[] try { - rows = await windowsProcessRowsReader.getSnapshot() + rows = + options.fresh === true + ? await windowsProcessRowsReader.getFreshSnapshot() + : await windowsProcessRowsReader.getSnapshot() } catch { return null } + // Why: a snapshot that omitted the PTY root may be stale or permission- + // filtered; only an observed root can authoritatively have no descendants. + if (!rows.some((row) => row.pid === rootPid)) { + return null + } return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) } @@ -234,7 +243,8 @@ async function queryWindowsProcessesWithWmic(): Promise 0 ? rows : null } catch { // Best-effort: Windows process enumeration may be disabled, so callers // still fall back to node-pty's process name when both probes fail. diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 7d0eeb5b4e3..41e9b3b9488 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -58,6 +58,20 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBe(true) }) + it('keeps quoted cmd.exe startup commands on stdin delivery', () => { + // Why: direct queued cmd commands need normal quotes, which node-pty's + // C-runtime argv escaping corrupts when delivered through `/K`. + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice\\repo', + 'C:\\Users\\alice', + undefined, + 'cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"' + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('keeps large cmd.exe startup commands on stdin delivery', () => { const result = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 3889edfc5c1..4977cc11158 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -55,6 +55,11 @@ function getCmdShellArgStartupCommand(command?: string): string | null { if (!command || command.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { return null } + // Why: node-pty's C-runtime argv escaping changes normal cmd quotes in `/K` + // delivery, while the interactive parser preserves them through stdin. + if (command.includes('"')) { + return null + } const commandArg = `${CMD_UTF8_SETUP_COMMAND} & ${command}` if (commandArg.length > CMD_EXE_COMMAND_LINE_MAX_CHARS) { return null 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/service.test.ts b/src/main/rate-limits/service.test.ts index 166442ee0ae..261a94f36be 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -29,14 +29,14 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() -})) - vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + vi.mock('./minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -73,7 +73,7 @@ async function flushMicrotasks(times = 4): Promise { } function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -93,7 +93,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], message: string ): ProviderRateLimits { return { @@ -106,6 +106,20 @@ function errorProvider( } } +function unavailableProvider( + provider: ProviderRateLimits['provider'], + message = 'Not configured' +): ProviderRateLimits { + return { + provider, + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'unavailable' + } +} + function serviceInternals(service: RateLimitService): { fetchAll: () => Promise } { return service as unknown as { fetchAll: () => Promise } } @@ -344,6 +358,187 @@ describe('RateLimitService', () => { } }) + it('recovers a failed deferred-startup Claude fetch on the next focus', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'auth restarting')) + .mockResolvedValueOnce(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(service.getState().claude?.status).toBe('error') + expect(service.getState().codex?.status).toBe('ok') + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('throttles repeated active-window retries while Claude is still failing', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a full-fetch retry on the 5-minute cadence for a provider without a dedicated fetch cycle', async () => { + vi.useFakeTimers() + try { + // Kimi has no individual fetch cycle, so recovering it re-runs fetchAll + // (which hits Claude's tight-budget endpoint). A durable Kimi error must + // not drive that full fetch every 30s — it stays on the 5-minute cadence. + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(errorProvider('kimi', 'token expired')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().kimi?.status).toBe('error') + + // First activation recovers immediately (retry timestamps start at 0). + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Well past the 30s failure throttle but inside the 5-minute window: the + // full fetch (and the Claude read it entails) must not fire again. + await vi.advanceTimersByTimeAsync(2 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // After the full 5-minute window the retry fires again. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('debounces unavailable providers on active window events', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('still debounces a focus event within the window after a successful fetch', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // A focus within MIN_REFETCH_MS after a GOOD fetch must still no-op. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index b3800b31807..cd301cfe516 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -59,6 +59,15 @@ type MiniMaxResolvedConfig = { } type GeminiCliOAuthEnabledResolver = () => boolean +type ActiveRateLimitProvider = ProviderRateLimits['provider'] +type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it @@ -67,6 +76,15 @@ const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: these providers have a dedicated fetch cycle, so an activation retry can +// refresh just the failing one. Providers without one force a full fetchAll, so +// their error retries stay on the 5-minute cadence to protect Claude's budget. +const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 @@ -122,7 +140,18 @@ export class RateLimitService { private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null - private lastFetchAt = 0 + // Why: after the first recovery attempt, repeated focus/show/restore events + // during the same outage should not create a tight provider retry loop. + private lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } private mainWindow: BrowserWindow | null = null private detachWindowListeners: (() => void) | null = null private isFetching = false @@ -698,17 +727,104 @@ export class RateLimitService { return this.mainWindow.isFocused() } + private getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a newly added provider is compile-forced to have + // an active-refresh entry — a missing one silently never recovers from a + // startup error (antigravity was omitted once and needed a fix-up). + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data. Keep it eligible for + // activation recovery while throttling repeated events per provider. + if (limits.status === 'error') { + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? ACTIVE_FAILURE_REFETCH_MS + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + await this.fetchAll() + return + } + + // Why: a fetch already in flight will refresh these providers; skip without + // consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: partial failures of providers with a dedicated fetch cycle should + // recover without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + private async refreshIfWindowActive(): Promise { if (!this.shouldBackgroundPoll()) { return } - // Why: startup intentionally skips the pre-paint fetch. The first visible - // activation must still populate usage after update relaunches where the - // timer can be focus-gated for a long time. - if (Date.now() - this.lastFetchAt < MIN_REFETCH_MS) { - return - } - await this.fetchAll() + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) } private async fetchAll(options?: { force?: boolean }): Promise { @@ -1411,8 +1527,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { @@ -1465,8 +1579,6 @@ export class RateLimitService { ...this.state, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex }) - - this.lastFetchAt = Date.now() } private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { @@ -1524,8 +1636,6 @@ export class RateLimitService { ? this.applyStalePolicy(claude, previousState.claude) : this.state.claude }) - - this.lastFetchAt = Date.now() } private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { @@ -1563,8 +1673,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private applyStalePolicy( 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/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eddb3f299cf..6213765bb20 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + it('elects one query responder and promotes the survivor on unsubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('excludes an older passive desktop-mode subscriber from reply authority', async () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + await vi.advanceTimersByTimeAsync(10) + runtime.markMobileActor('pty-1', 'phone-B') + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + }) + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime-path-candidate-history.test.ts b/src/main/runtime/orca-runtime-path-candidate-history.test.ts new file mode 100644 index 00000000000..80b09ed81e3 --- /dev/null +++ b/src/main/runtime/orca-runtime-path-candidate-history.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendRecentPtyPathCandidates } from './orca-runtime' + +function fullCandidateHistory(): string[] { + return Array.from( + { length: 1024 }, + (_, index) => `/tmp/artifact-${index.toString().padStart(4, '0')}.json` + ) +} + +describe('PTY path candidate history', () => { + it('reuses path-candidate history across repeated pathless PTY output', () => { + const previous = fullCandidateHistory() + const byteLengthSpy = vi.spyOn(Buffer, 'byteLength') + let candidates = previous + let replacements = 0 + let byteLengthCalls = 0 + try { + for (let index = 0; index < 4096; index += 1) { + const next = appendRecentPtyPathCandidates( + candidates, + 'ordinary compiler progress without a path\n' + ) + if (next !== candidates) { + replacements += 1 + } + candidates = next + } + } finally { + byteLengthCalls = byteLengthSpy.mock.calls.length + byteLengthSpy.mockRestore() + } + + expect(candidates).toBe(previous) + expect(replacements).toBe(0) + expect(byteLengthCalls).toBe(0) + }) + + it('copies history when new output adds a path candidate', () => { + const previous = fullCandidateHistory() + const changed = appendRecentPtyPathCandidates(previous, 'wrote /tmp/new-artifact.json\n') + + expect(changed).not.toBe(previous) + expect(changed).toContain('/tmp/new-artifact.json') + expect(previous).not.toContain('/tmp/new-artifact.json') + }) +}) diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index 7f3c8a5201f..f61431b7b86 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { appendNormalizedToTailBuffer, buildPreview, @@ -10,8 +10,8 @@ import { // These tests pin the onPtyData wait-detection memoization: caching the // post-append wait state and reusing it as the next chunk's pre-append state // must produce byte-for-byte the same waitBlockedAt stamping as recomputing the -// full-tail scan on both sides of every chunk (the pre-memoization behavior), -// while doing roughly half the full-tail work. +// wait-state scan on both sides of every chunk (the pre-memoization behavior), +// while doing roughly half the state computations. type RedrawCursor = ReturnType['redrawCursor'] @@ -137,6 +137,21 @@ describe('onPtyData tail wait memoization', () => { expect(blocked.signal?.reason).toBe('codex-update-prompt') }) + it('does not rebuild or repeatedly scan an ordinary saturated tail', () => { + const lines = Array.from({ length: 2000 }, () => 'x'.repeat(126)) + const lastIndexOf = vi.spyOn(String.prototype, 'lastIndexOf') + + try { + const state = computeTerminalTailWaitState(lines, '', '') + + expect(state.signal).toBeNull() + expect(state.waitText).toBe('') + expect(lastIndexOf).not.toHaveBeenCalled() + } finally { + lastIndexOf.mockRestore() + } + }) + for (const [name, chunks] of Object.entries(SCENARIOS)) { it(`memoized stamping matches recompute reference: ${name}`, () => { const { memoized, reference } = runBoth(chunks) @@ -194,7 +209,7 @@ describe('onPtyData tail wait memoization', () => { expect(memoSim.waitBlockedAt).not.toBeNull() }) - it('does roughly half the full-tail wait scans of the recompute reference', () => { + it('does roughly half the wait-state computations of the recompute reference', () => { const chunks: string[] = [] for (let i = 0; i < 500; i += 1) { chunks.push(`streaming line ${i}\n`) 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 2b515e230bb..03a55a02a19 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -41,7 +42,7 @@ import { runHook, shouldRunSetupForCreate } from '../hooks' -import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo' +import { getBaseRefDefault, getBranchConflictKind } from '../git/repo' import type { OrchestrationDb } from './orchestration/db' import type { MessagePriority, MessageRow, MessageType } from './orchestration/types' import { @@ -91,6 +92,7 @@ import { TERMINAL_METHODS } from './rpc/methods/terminal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) +const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) vi.mock('../ipc/worktree-symlinks', () => ({ createWorktreeLinkedPaths: vi.fn(), @@ -516,23 +518,30 @@ vi.mock('../github/issues', async (importOriginal) => { } }) -// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a -// fallback base branch. Real resolution shells out to `git` against the -// test's fabricated repo path, which has no refs, so we stub it to a -// predictable 'origin/main'. The runtime no longer silently fabricates this -// default, so tests that want the legacy behavior must express it via the mock. +// Why: CLI worktree creation resolves a default against fabricated repo paths +// in these tests, so keep the async resolver deterministic. vi.mock('../git/repo', async (importOriginal) => { const actual = (await importOriginal()) as Record + const actualGetBaseRefDefault = actual.getBaseRefDefault as ( + path: string, + options?: { wslDistro?: string } + ) => Promise return { ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + // Why: fabricated local test repos need a deterministic default, while + // WSL coverage must still exercise the real async Git-options path. + getBaseRefDefault: vi + .fn() + .mockImplementation((path: string, options?: { wslDistro?: string }) => + options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main') + ), getBranchConflictKind: vi.fn().mockResolvedValue(null) } }) vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') - return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } + return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock } }) function resetRuntimeTestMocks(): void { @@ -552,6 +561,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined) vi.mocked(removeWorktree).mockReset() removeWorktreeLinkedPathsMock.mockReset() + resolveLocalGitUsernameMock.mockReset().mockResolvedValue('') vi.mocked(forceDeleteLocalBranchMock).mockReset() vi.mocked(forceDeleteLocalBranchMock).mockResolvedValue(undefined) sshGitProviders.clear() @@ -874,6 +884,32 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High ].join('\n') } +// Why: verbatim shape of a real cursor-agent 2026.07 idle screen. The fresh +// input prompt is "→ Plan, search, build anything" (not "→ Add a follow-up", +// which only appears after the first turn), so the matcher keys on the "→" +// glyph, not a specific placeholder. +function cursorReadyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + 'Tip: Use /plan to plan execution and reach the right outcome faster.', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + +function cursorBusyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + '⠰⠳ Thinking 28.61k tokens', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + // Why: these runtime feature tests only need message-queue semantics; using // SQLite here makes them fail on unrelated native runtime ABI drift. class InMemoryOrchestrationMessages { @@ -907,7 +943,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 @@ -1140,6 +1177,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 { @@ -2160,6 +2258,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() @@ -2482,6 +2594,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) @@ -2490,6 +2668,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() @@ -2825,7 +3024,7 @@ describe('OrcaRuntimeService', () => { suggestLocalBaseRefUpdate: true } ) - expect(getDefaultBaseRef).toHaveBeenCalled() + expect(getBaseRefDefault).toHaveBeenCalled() } finally { getReposSpy.mockRestore() gitSpy.mockRestore() @@ -3022,6 +3221,7 @@ describe('OrcaRuntimeService', () => { 'origin/feature/something', false ) + expect(resolveLocalGitUsernameMock).not.toHaveBeenCalled() expect(result.worktree).toMatchObject({ path: '/tmp/workspaces/feature-something', branch: 'feature/something' @@ -5330,7 +5530,7 @@ describe('OrcaRuntimeService', () => { it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() - vi.mocked(getDefaultBaseRef).mockClear() + vi.mocked(getBaseRefDefault).mockClear() const remoteStore = { ...store, getRepos: () => [ @@ -5366,7 +5566,7 @@ describe('OrcaRuntimeService', () => { } expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') - expect(getDefaultBaseRef).not.toHaveBeenCalled() + expect(getBaseRefDefault).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() }) @@ -5432,7 +5632,7 @@ describe('OrcaRuntimeService', () => { }) expect(asyncGitSpy).toHaveBeenCalledWith( ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - wslGitOptions + { ...wslGitOptions, timeout: 15_000 } ) expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions) expect(asyncGitSpy).toHaveBeenCalledWith( @@ -6153,6 +6353,43 @@ describe('OrcaRuntimeService', () => { } }) + it('drops a same-path negative submodule cache before runtime cloneRepo', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-')) + const clonePath = join(destination, 'reclone') + const added: Record[] = [] + const cloneStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => added.push(repo), + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + void mkdir(clonePath, { recursive: true }) + .then(() => + writeFile(join(clonePath, '.gitmodules'), '[submodule "lib"]\n\tpath = vendor/lib\n') + ) + .then(() => proc.emit('close', 0, null)) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(cloneStore as never) + + try { + clearSubmodulePathsCacheForTests() + await expect(listSubmodulePaths(clonePath)).resolves.toEqual([]) + await runtime.cloneRepo('https://example.com/reclone.git', destination) + await expect(listSubmodulePaths(clonePath)).resolves.toEqual(['vendor/lib']) + } finally { + clearSubmodulePathsCacheForTests() + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn') spawnSpy.mockImplementation(() => { @@ -8409,6 +8646,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({ @@ -10525,6 +10959,72 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle for an idle Cursor lane past its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + // Cursor's dismissed trust dialog stays in the scrollback tail; the idle + // ready prompt after it must both clear the stale trust hit and satisfy idle. + runtime.onPtyData( + 'pty-bg', + [ + // Trust dialog body can mention "Cursor Agent" before the ready banner; + // lastIndexOf must pick the later banner, not this earlier hit. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorReadyScreen() + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running' + }) + }) + + it('does not block a mid-run Cursor lane on its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + // Same earlier "Cursor Agent" hit as the idle case — banner must win. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorBusyScreen() + ].join(''), + Date.now() + ) + + // Busy Cursor is neither blocked nor idle: the wait must keep polling, then + // time out honestly rather than return a stale trust block. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + ).rejects.toThrow('timeout') + }) + it('returns a blocked wait result for Codex cwd selection prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -26337,6 +26837,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(gitSpy).toHaveBeenCalledWith( @@ -27004,6 +27505,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(getBranchConflictKind).toHaveBeenCalledWith( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6152a33c720..7f5627fc3ee 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -53,6 +53,7 @@ import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' +import { runWithGitReadCacheInvalidation } from '../git/status' import { cleanupClaimedCloneTarget, claimCloneTarget, @@ -178,6 +179,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' @@ -599,7 +601,6 @@ import type { } from '../../shared/github-project-types' import { getBaseRefDefault, - getDefaultBaseRef, getDefaultRemote, getBranchConflictKind, isGitRepo, @@ -610,6 +611,7 @@ import { parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, mergeBaseRefSearchResultGroups, @@ -852,7 +854,6 @@ type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] - experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null @@ -1205,6 +1206,7 @@ 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 @@ -1377,7 +1379,7 @@ type RuntimeNotifier = { resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, - mode: 'mobile-fit' | 'desktop-fit', + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void @@ -2014,6 +2016,16 @@ class RuntimeLineageError extends Error { } } +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 ResolvedWorktreeCache = { expiresAt: number worktrees: ResolvedWorktree[] @@ -2061,6 +2073,7 @@ export type DriverState = RuntimeTerminalDriverState export type PtyLayoutTarget = | { kind: 'desktop'; cols: number; rows: number } | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + | { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string } // Why: authoritative layout state with monotonic seq. Bumped on every // applyLayout success; emitted on mobile subscribe-stream events so clients @@ -2179,7 +2192,13 @@ export class OrcaRuntimeService { // invoked from resizeForClient and onClientDisconnected/onPtyExit. private fitOverrideListeners = new Map< string, - Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> + Set< + (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + > >() private driverListeners = new Map void>>() private subscriptionCleanups = new Map void>() @@ -2328,6 +2347,20 @@ export class OrcaRuntimeService { private currentDriver = new Map() private currentBrowserDriver = new Map() + // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by + // subscription, not client, because one client can open duplicate streams and + // each stream must release only the width floor it registered. + private remoteDesktopViewers = new Map< + string, + Map + >() + private remoteDesktopOwners = new Map() + private remoteDesktopActivity = 0 + private remoteDesktopHostReclaimTargets = new Map() + // Why: a completed host reclaim must not consume the cache if a newer + // viewer mutation landed while that serialized layout was in flight. + private remoteDesktopViewerRevisions = new Map() + // Why: resubscribe-grace window. When the last mobile subscriber for a // PTY unsubscribes, we hold the driver=mobile{clientId} state and the // inner-map record open for ~250ms. If the same (ptyId, clientId) @@ -2979,6 +3012,14 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) } + // Why: renderer-initiated meta updates intentionally skip the renderer + // notifier (the renderer already applied them optimistically), but remote + // clients hold no optimistic copy and need the invalidation event. + notifyWorktreesChangedForRemoteClients(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, @@ -3166,7 +3207,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() @@ -4150,7 +4191,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) @@ -4426,7 +4467,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) @@ -4621,7 +4662,7 @@ export class OrcaRuntimeService { 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) @@ -4677,7 +4718,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 @@ -4710,7 +4751,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 @@ -6467,9 +6508,38 @@ export class OrcaRuntimeService { return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 } + isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { + // Why: a passive phone watching desktop-sized output must not race the + // desktop xterm. Mobile becomes reply authority only with the mobile floor. + if (this.getDriver(ptyId).kind !== 'mobile') { + return false + } + const subscribers = this.mobileSubscribers.get(ptyId) + if (!subscribers) { + return false + } + // Why: soft-leave resubscribe preserves the original subscription time but + // reinserts the record. Elect fitted responders from that stable age, not + // mutable Map order or passive desktop-mode watchers. + let earliest: { clientId: string; subscribedAt: number } | null = null + for (const subscriber of subscribers.values()) { + if (!subscriber.wasResizedToPhone) { + continue + } + if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { + earliest = subscriber + } + } + return earliest?.clientId === clientId + } + subscribeToFitOverrideChanges( ptyId: string, - listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void + listener: (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void ): () => void { return addListenerToMap(this.fitOverrideListeners, ptyId, listener) } @@ -6480,7 +6550,7 @@ export class OrcaRuntimeService { private notifyFitOverrideListeners( ptyId: string, - mode: 'mobile-fit' | 'desktop-fit', + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void { @@ -7757,11 +7827,26 @@ export class OrcaRuntimeService { return this.terminalFitOverrides.get(ptyId) ?? null } - getAllTerminalFitOverrides(): Map { - const result = new Map() + getAllTerminalFitOverrides(): Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + > { + const result = new Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + >() for (const [ptyId, override] of this.terminalFitOverrides) { result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) } + for (const [ptyId] of this.remoteDesktopOwners) { + if (result.has(ptyId)) { + continue + } + const size = this.getTerminalSize(ptyId) + if (size) { + result.set(ptyId, { mode: 'remote-desktop-fit', ...size }) + } + } return result } @@ -7842,7 +7927,16 @@ export class OrcaRuntimeService { // at phone dims after the phone disconnects; the desktop banner's // Restore button is the explicit return path. See // docs/mobile-fit-hold.md. - if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } // Use the soft-leaver's snapshot baseline as a hint, falling // through to resolveDesktopRestoreTarget for missing values. const fallback = this.resolveDesktopRestoreTarget(ptyId) @@ -7881,7 +7975,16 @@ export class OrcaRuntimeService { for (const { ptyId, baseline } of ptysToRestore) { const cur = this.layouts.get(ptyId) // Why: Indefinite hold gate — see soft-leaver branch above. - if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } const fallback = this.resolveDesktopRestoreTarget(ptyId) const cols = baseline?.cols ?? fallback.cols const rows = baseline?.rows ?? fallback.rows @@ -8010,6 +8113,10 @@ export class OrcaRuntimeService { this.currentDriver.delete(ptyId) this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) } + this.remoteDesktopViewers.delete(ptyId) + this.remoteDesktopOwners.delete(ptyId) + this.remoteDesktopHostReclaimTargets.delete(ptyId) + this.remoteDesktopViewerRevisions.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) const pty = this.ptysById.get(ptyId) @@ -8065,6 +8172,326 @@ export class OrcaRuntimeService { } } + // Why: the host's own fit cascade (window resize, split drag, tab reveal, + // "+"-new-tab re-render) must not resize a PTY whose width a remote client + // owns — that is the remote "porridge" bug. True while a phone (mobile driver) + // OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated + // here (see the `writePtyInput` mobile-only checks): shared-control desktop + // viewers may still type alongside the host. + // Note: this is intentionally NOT a driver kind. An active remote viewer needs + // only resize suppression, not the mobile driver machinery (input lock, + // phone-fit, driver-change banners), so it lives in its own registry and does + // not perturb the presence-lock state machine. It also coexists with mobile: + // while a phone drives, the registry still suppresses host resize, and when + // the phone leaves the surviving viewer keeps the PTY suppressed. + isPtyResizeDrivenRemotely(ptyId: string): boolean { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + return this.isRemoteDesktopResizeDriven(ptyId) + } + + isRemoteDesktopResizeDriven(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) + } + + isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { + return this.remoteDesktopOwners.get(ptyId) === subscriptionKey + } + + getRemoteDesktopFitHold( + ptyId: string, + subscriptionKey: string + ): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } { + const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 } + return { + mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey) + ? 'desktop-fit' + : 'remote-desktop-fit', + ...size + } + } + + private hasRemoteDesktopViewers(ptyId: string): boolean { + const viewers = this.remoteDesktopViewers.get(ptyId) + return viewers !== undefined && viewers.size > 0 + } + + private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null { + const owner = this.remoteDesktopOwners.get(ptyId) + return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null + } + + private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } { + const target = this.remoteDesktopHostReclaimTargets.get(ptyId) + if (target) { + return target + } + // Why: a viewer can join while a phone owns the actual PTY size. The + // mobile restore chain retains the pre-phone desktop geometry; current + // PTY size alone would incorrectly capture the phone grid as host truth. + return this.resolveDesktopRestoreTarget(ptyId) + } + + private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void { + if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set( + ptyId, + this.resolveRemoteDesktopHostReclaimTarget(ptyId) + ) + } + } + + recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void { + // Why: phone presence also suppresses host resize, but must not seed the + // separate remote-viewer cache when no desktop stream owns a width floor. + if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) { + return + } + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } + + private hasRemoteDesktopLayoutState(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId) + } + + private bumpRemoteDesktopViewerRevision(ptyId: string): number { + const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1 + this.remoteDesktopViewerRevisions.set(ptyId, revision) + return revision + } + + async applyRemoteDesktopLayout(ptyId: string): Promise { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + const target = this.activeRemoteDesktopViewport(ptyId) + const reclaimingHost = !target + const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0 + const layoutTarget: PtyLayoutTarget = target + ? { + kind: 'remote-desktop', + cols: target.cols, + rows: target.rows, + ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)! + } + : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, layoutTarget) + // Why: only drop the recorded host size once the reclaim resize actually + // landed. If it failed, the PTY is still at the remote-viewer width, so + // keep the target for the next reclaim (otherwise it resolves via the + // stale remote width and never restores true host geometry). + if ( + reclaimingHost && + result.ok && + !this.remoteDesktopOwners.has(ptyId) && + this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision + ) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + + // Why: attachment only records geometry. Passive hydration/reconnect must not + // steal the shared PTY from the desktop where the user is actively working. + async updateRemoteDesktopViewer( + ptyId: string, + subscriptionKey: string, + clientId: string, + cols: number, + rows: number, + claim = true + ): Promise { + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + viewers = new Map< + string, + { clientId: string; cols: number; rows: number; activity: number } + >() + this.remoteDesktopViewers.set(ptyId, viewers) + } + const prior = viewers.get(subscriptionKey) + if ( + prior && + prior.cols === viewport.cols && + prior.rows === viewport.rows && + (!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey) + ) { + if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + if (size?.cols !== viewport.cols || size.rows !== viewport.rows) { + return this.applyRemoteDesktopLayout(ptyId) + } + } + return true + } + const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0) + viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity }) + this.bumpRemoteDesktopViewerRevision(ptyId) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + return this.applyRemoteDesktopLayout(ptyId) + } + return true + } + + claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey) + if (!viewer) { + return Promise.resolve(false) + } + if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + return size?.cols === viewer.cols && size.rows === viewer.rows + ? Promise.resolve(true) + : this.applyRemoteDesktopLayout(ptyId) + } + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + viewer.activity = ++this.remoteDesktopActivity + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { + if (!this.remoteDesktopOwners.has(ptyId)) { + // Why: disconnect can remove the owner before its queued host resize + // lands. A host input in that window must join the reclaim, not pass it. + return this.remoteDesktopHostReclaimTargets.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + const viewport = clampTerminalViewport(cols, rows) + this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) + this.remoteDesktopOwners.delete(ptyId) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) + } + + unregisterRemoteDesktopViewers( + ptyId: string, + subscriptionKeys: Iterable + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + let changed = false + let removedOwner = false + for (const subscriptionKey of subscriptionKeys) { + removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner + changed = viewers.delete(subscriptionKey) || changed + } + if (!changed) { + return Promise.resolve(false) + } + if (viewers.size === 0) { + this.remoteDesktopViewers.delete(ptyId) + } + if (removedOwner) { + let fallback: { key: string; activity: number } | null = null + for (const [key, viewer] of viewers) { + if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) { + fallback = { key, activity: viewer.activity } + } + } + if (fallback) { + this.remoteDesktopOwners.set(ptyId, fallback.key) + } else { + this.remoteDesktopOwners.delete(ptyId) + } + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) + } + + // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so + // it must never *create* a width floor (that floor would leak — nothing + // releases it, pinning the host at a stale width after the viewer is gone). + // It only refreshes the floor(s) this client already owns via its stream + // subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport` + // no-op-without-subscription invariant. Returns false when the client owns no + // floor (passive/stream-less viewer) — a stream-less viewer must not lock host + // resize. + refreshRemoteDesktopViewer( + ptyId: string, + clientId: string, + cols: number, + rows: number, + claim = false + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + // Why: terminal.send may be the first activity while the stream is only + // passively registered. Snapshot host truth before this refresh owns it. + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let changed = false + for (const [subscriptionKey, viewer] of viewers) { + if (viewer.clientId === clientId) { + const activity = claim ? ++this.remoteDesktopActivity : viewer.activity + viewers.set(subscriptionKey, { + ...viewer, + cols: viewport.cols, + rows: viewport.rows, + activity + }) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + } + changed = true + } + } + if (!changed) { + return Promise.resolve(false) + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.remoteDesktopOwners.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + + async updateDesktopViewport( + ptyId: string, + viewport: { cols: number; rows: number } + ): Promise { + const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) + if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') { + this.recordRendererGeometry(ptyId, cols, rows) + return true + } + if (this.isResizeSuppressed()) { + return false + } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.refreshRendererGeometry(ptyId, cols, rows) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + markMobileActor(ptyId: string, clientId: string): void { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) @@ -8165,37 +8592,6 @@ export class OrcaRuntimeService { return { updated: true, applied: result.ok } } - // Why: remote desktop clients do not have the local `pty:resize` IPC path. - // Their measured xterm size still has to resize the source PTY so TUIs - // reflow to the visible client dimensions. - async updateDesktopViewport( - ptyId: string, - viewport: { cols: number; rows: number } - ): Promise { - const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) - if (this.terminalFitOverrides.has(ptyId)) { - // Why: remote desktop panes do not have the local pty:reportGeometry - // IPC. While phone-fit holds the PTY, treat their viewport RPC as a - // measurement-only restore target, not a resize intent. - this.recordRendererGeometry(ptyId, cols, rows) - return true - } - if (this.isResizeSuppressed() || this.getDriver(ptyId).kind === 'mobile') { - return false - } - let resized = false - try { - resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false - } catch { - return false - } - if (!resized) { - return false - } - this.onExternalPtyResize(ptyId, cols, rows) - return true - } - // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take // back" / "Restore" button). Forces the PTY back to desktop dims and flips // the driver to `desktop`, suppressing further mobile-driven dim changes @@ -8227,9 +8623,34 @@ export class OrcaRuntimeService { // the terminal tab on the phone) must default to phone-fit again, not stay // in passive desktop-watch mode. this.setMobileDisplayMode(ptyId, 'auto') + if (this.hasRemoteDesktopLayoutState(ptyId)) { + return this.applyRemoteDesktopLayout(ptyId) + } return true } const heldOverride = this.terminalFitOverrides.get(ptyId) + if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (softLeaver) { + clearTimeout(softLeaver.timer) + this.pendingSoftLeavers.delete(ptyId) + } + const priorDriver = this.getDriver(ptyId) + this.setDriver(ptyId, { kind: 'idle' }) + const converged = await this.applyRemoteDesktopLayout(ptyId) + if (!converged) { + this.setDriver(ptyId, priorDriver) + return false + } + this.setDriver(ptyId, { kind: 'desktop' }) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } if (heldOverride) { const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { @@ -8446,6 +8867,11 @@ export class OrcaRuntimeService { if (prev.kind === 'phone' && next.kind === 'phone') { return prev.ownerClientId === next.ownerClientId } + if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') { + // Why: each owner's claim promise gates its following input. Sharing a + // waiter across owners could release A's input only after B's grid lands. + return prev.ownerSubscriptionKey === next.ownerSubscriptionKey + } return true } @@ -8583,9 +9009,9 @@ export class OrcaRuntimeService { this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) } - // Why: emit fit-override-changed when the *mode* flips. Layouts can - // change dims without flipping mode (keyboard show/hide while phone), - // and waking the renderer on every viewport tick is wasteful churn. + // Why: remote desktop ownership is a fit hold for the host and passive + // peer viewers. Emit every remote layout so owner changes at equal geometry + // still park/release the correct clients without relying on resize deltas. // Defense-in-depth (#7588): also emit when the override's presence // changed even without a kind flip. applyLayout is the sole writer and // keeps override presence in lockstep with layout kind, so overrideChanged @@ -8593,7 +9019,7 @@ export class OrcaRuntimeService { // only if that invariant is ever violated, repairing the renderer instead // of stranding the held modal. const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') - if (modeChanged || overrideChanged) { + if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". @@ -8603,13 +9029,21 @@ export class OrcaRuntimeService { } this.notifier?.terminalFitOverrideChanged( ptyId, - target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', target.cols, target.rows ) this.notifyFitOverrideListeners( ptyId, - target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', target.cols, target.rows ) @@ -8929,6 +9363,9 @@ export class OrcaRuntimeService { this.pendingSoftLeavers.delete(ptyId) if (!this.mobileSubscribers.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) + if (this.hasRemoteDesktopViewers(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + } } }, SOFT_LEAVE_GRACE_MS) if (typeof softTimer.unref === 'function') { @@ -8981,6 +9418,10 @@ export class OrcaRuntimeService { if (this.isMobileSubscriberActive(ptyId)) { return } + if (this.hasRemoteDesktopLayoutState(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + return + } void this.enqueueLayout(ptyId, { kind: 'desktop', cols: restoreCols, @@ -9116,6 +9557,11 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + // Why: a successful host resize supersedes any target retained after a + // failed viewer reclaim; a later viewer cycle must capture this new truth. + if (!this.hasRemoteDesktopViewers(ptyId)) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } @@ -9136,6 +9582,11 @@ export class OrcaRuntimeService { if (cols <= 0 || rows <= 0) { return } + // Why: a viewer may leave while phone-fit still owns the PTY. Keep its + // deferred host reclaim cache aligned with later trusted pane measurements. + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } this.refreshRendererGeometry(ptyId, cols, rows) } @@ -9252,7 +9703,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 = @@ -9641,6 +10092,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. @@ -9787,7 +10245,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 = @@ -9811,7 +10270,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, @@ -9822,14 +10282,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 @@ -9838,8 +10326,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 @@ -9877,6 +10365,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 }, @@ -9891,21 +10382,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( @@ -11462,12 +11968,14 @@ export class OrcaRuntimeService { try { await previous - return await this.cloneRepoAfterPathLock( - trimmedUrl, - trimmedDestination, - clonePath, - clonePathKey, - executionHostId + return await runWithGitReadCacheInvalidation(() => + this.cloneRepoAfterPathLock( + trimmedUrl, + trimmedDestination, + clonePath, + clonePathKey, + executionHostId + ) ) } finally { release() @@ -14134,6 +14642,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 @@ -14157,6 +14666,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}` @@ -14194,6 +14711,7 @@ export class OrcaRuntimeService { isMainWorktree: worktree.isMainWorktree } }, + ...(startupTerminal ? { startupTerminal } : {}), ...(warning ? { warning } : {}) } } @@ -14247,15 +14765,20 @@ export class OrcaRuntimeService { const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) let effectiveSanitizedName = sanitizedName - const username = await resolveLocalGitUsername(repo.path) + // Why: explicit branches and non-username prefix modes never consume this + // value; skipping the probes preserves the exact generated branch name. + const username = + !args.branchNameOverride && settings.branchPrefix === 'git-username' + ? await resolveLocalGitUsername(repo.path) + : '' const baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => hasLocalWorktreeGitOptions - ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) - : Promise.resolve(getDefaultBaseRef(repo.path)), + ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) + : getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, @@ -14286,10 +14809,8 @@ export class OrcaRuntimeService { } }) if (!baseBranch) { - // Why: getDefaultBaseRef returns null when no suitable ref exists. - // Don't fabricate 'origin/main' — passing it to addWorktree would - // produce an opaque git failure. Surface a clear error so the CLI - // caller can pick an explicit --base ref. + // Why: a null default means no suitable ref exists; fail clearly instead + // of handing Git a fabricated origin/main ref. throw new Error( 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) @@ -14703,11 +15224,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) } @@ -18581,7 +19098,22 @@ 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 resolveWorktreeSelector(selector: string): Promise { + const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) const worktrees = await this.listResolvedWorktrees() let candidates: ResolvedWorktree[] @@ -18590,7 +19122,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) @@ -18717,6 +19249,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: [] } } @@ -18746,15 +19287,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 } ) } @@ -18774,15 +19312,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 } ) } @@ -19967,7 +20502,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( @@ -20442,7 +20977,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 } @@ -24214,8 +24750,14 @@ export function appendRecentPtyPathCandidates( previous: string[] | undefined, data: string ): string[] { + const extractedCandidates = extractTerminalOutputPathCandidates(data) + if (extractedCandidates.length === 0) { + // Why: pathless output is the common hot path. Reuse immutable history so + // each PTY chunk does not clone and byte-scan up to 1,024 old candidates. + return previous ?? [] + } const next = previous ? previous.slice() : [] - for (const candidate of extractTerminalOutputPathCandidates(data)) { + for (const candidate of extractedCandidates) { if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { continue } @@ -24522,17 +25064,28 @@ export type TerminalTailWaitState = { fromTail: boolean } -// Why: onPtyData runs per raw PTY chunk (hundreds/sec under load). Building the -// wait text (a full map/trim/filter/join over the up-to-256KB retained tail) -// and lower-casing + scanning it once per chunk is unavoidable, but the old -// code did it twice — once for the pre-append tail and once for the post-append -// tail — every chunk. Caching the post-append state lets the next chunk reuse it -// as its pre-append state, halving the per-chunk full-tail work. +// Why: onPtyData runs per raw PTY chunk (hundreds/sec under load). Ordinary +// tails take one no-join sentinel pass; only candidate-bearing tails +// build, lowercase, and parse the full 256 KiB text. The cached post-append +// state also avoids repeating that work for the next chunk's previous state. export function computeTerminalTailWaitState( lines: string[], partialLine: string, preview: string ): TerminalTailWaitState { + const tailShape = inspectTerminalWaitTail(lines, partialLine) + if (!tailShape.fromTail) { + return { + waitText: preview, + signal: findActionableTerminalWaitBlockedSignal(preview.toLowerCase()), + fromTail: false + } + } + if (!tailShape.mayContainBlockedSignal) { + // Why: tailGainedNewerBlockedReason reads waitText only when signal exists; + // avoid retaining a rebuilt 256 KiB string for the overwhelmingly common case. + return { waitText: '', signal: null, fromTail: true } + } const tailText = buildTailLines(lines, partialLine) .map((line) => line.trim()) .filter(Boolean) @@ -24546,6 +25099,29 @@ export function computeTerminalTailWaitState( } } +function inspectTerminalWaitTail( + lines: string[], + partialLine: string +): { fromTail: boolean; mayContainBlockedSignal: boolean } { + let fromTail = false + let mayContainBlockedSignal = false + for (const line of lines) { + if (!fromTail && line.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + mayContainBlockedSignal = true + } + } + if (!fromTail && partialLine.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { + mayContainBlockedSignal = true + } + return { fromTail, mayContainBlockedSignal } +} + // Why: decides whether the appended chunk introduced a newer actionable blocked // prompt, consuming precomputed wait states so the full-tail scans are not // repeated per chunk (replaces the former inline double full-tail scan). @@ -25516,20 +26092,64 @@ function findActionableTerminalWaitBlockedSignal( if (blockedSignal === null) { return null } - const readyIndex = findKnownReadyPromptIndex(normalized) + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) // Why: retained terminal tails can include stale startup modals. If a known - // ready prompt appears after that modal, the latest signal is ready. - return readyIndex !== null && readyIndex > blockedSignal.index ? null : blockedSignal + // agent's live prompt appears after that modal, the modal was dismissed and + // the signal is no longer actionable — even if the agent is still mid-run + // (Cursor never reports idle via OSC title, so its busy prompt clears too). + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +// Why: a recognized agent's live prompt (idle OR busy) proves its startup modal +// was dismissed. Broader than the idle-only ready set so a mid-run Cursor lane +// stops reporting a stale trust hit for the rest of the session. +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null } function findKnownReadyPromptIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized) + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } +// Why: cursor-agent keeps a persistent TUI — a printed "Cursor Agent" banner and +// a "→" input-prompt line appear once its trust dialog is dismissed, in both +// busy and idle states. The banner is matched by its last occurrence so the +// trust dialog's own "Cursor Agent" body text (which precedes the banner) does +// not win. The "→" glyph is cursor-agent's input prompt marker ("→ Plan, +// search, build anything" fresh, "→ Add a follow-up" after the first turn). +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +// Why: cursor-agent never emits an idle OSC title (its bare title is dropped), +// so tui-idle can only resolve from the tail. Busy frames draw a braille +// spinner in the on-screen status line; its absence past the banner is idle. +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { @@ -25587,9 +26207,17 @@ function isTerminalWaitWhitespace(value: string, index: number): boolean { return code === 32 || (code >= 9 && code <= 13) } +const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + function findTerminalWaitBlockedSignal( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + // Why: this runs once per PTY chunk over a tail up to 256 KiB. One combined + // negative scan avoids a dozen full-tail searches when no prompt can match. + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { 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/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 8593fd81622..1e94018c931 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -144,6 +144,31 @@ describe('Coordinator', () => { expect(runtime.sentMessages.length).toBeGreaterThan(0) }) + 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 () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() @@ -570,7 +595,7 @@ 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 payload provenance after an assignee handle changes', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() const logs: string[] = [] @@ -579,9 +604,9 @@ describe('Coordinator', () => { const ctx = db.createDispatchContext(task.id, 'term_owner') 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 }) }) @@ -592,16 +617,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('accepting payload provenance'))).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..e09e48942d3 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -29,6 +29,9 @@ 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 (§3.1): single threshold, no warn/refuse split. Coordinator picked 20 @@ -461,7 +464,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). diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 8f43298a92c..8856101e1ee 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -339,6 +339,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' }) @@ -822,6 +869,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..ddbdb54a0f6 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, @@ -41,7 +53,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 +90,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 +123,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 +263,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 +318,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 +334,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 } @@ -382,6 +409,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 +609,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 +625,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 +647,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 +672,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 { diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index 28182e3dbfb..f932aec359c 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 } } diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 483c8a1fe83..5f7f07ca0ec 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -30,6 +30,7 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@idle')).toBe(true) expect(isGroupAddress('@claude')).toBe(true) expect(isGroupAddress('@droid')).toBe(true) + expect(isGroupAddress('@grok')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -169,6 +170,54 @@ 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']) + }) }) describe('unknown groups', () => { diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index c897ae03866..39ba290bdfa 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,3 +1,4 @@ +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,7 +12,8 @@ const AGENT_NAME_GROUPS = [ 'opencode', 'mimo', 'gemini', - 'droid' + 'droid', + 'grok' ] as const export type GroupAddress = @@ -24,13 +26,11 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { - const tokenRe = new RegExp(`(? { + let db: OrchestrationDb + + afterEach(() => db?.close()) + + it('completes an active dispatch from payload IDs after its terminal handle is reminted', () => { + 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))).toEqual({ + action: 'completed', + taskId: task.id, + dispatchId: dispatch.id + }) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(logs.some((line) => line.includes('accepting payload provenance'))).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('completes worker_done when a pane key is unparseable (legacy format)', () => { + 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('completed') + }) + + it('ignores worker_done sent from a different pane', () => { + 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))).toEqual({ + action: 'ignored' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(logs.some((line) => line.includes(`expected pane tab_w1:${LEAF_A}`))).toBe(true) + }) + + it('ignores 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)).toEqual({ action: 'ignored' }) + 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..d784840a28c 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -1,8 +1,26 @@ import type { OrchestrationDb } from './db' import type { MessageRow } from './types' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +// Why: the tab half of a pane key changes when a pane is broken out into its +// own tab, so only the leaf UUID is identity. Reject only when both keys +// parse and name different leaves; an unparseable (e.g. legacy numeric) key +// degrades to payload authority rather than stranding a completion. +function isForeignPane(assigneePaneKey: string, senderPaneKey: string): boolean { + if (assigneePaneKey === senderPaneKey) { + return false + } + const assigneeLeaf = parsePaneKey(assigneePaneKey)?.leafId + const senderLeaf = parsePaneKey(senderPaneKey)?.leafId + return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf !== senderLeaf) +} 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' } | { action: 'completed'; taskId: string; dispatchId: string } | { action: 'heartbeat_recorded'; dispatchId: string } @@ -63,6 +81,28 @@ function reconcileHeartbeatMessage( 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 ( + dispatch.assignee_pane_key && + msg.sender_pane_key && + isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) + ) { + // Why: a wrong-pane heartbeat must not refresh liveness — it would mask + // a hung assignee behind another agent's timer. + onLog( + `Heartbeat for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` + ) + return { action: 'ignored' } + } + // 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) @@ -112,10 +152,25 @@ function reconcileWorkerDoneMessage( return { action: 'ignored' } } if (dispatch.assignee_handle !== msg.from_handle) { + // Why: pane leaves are the remint-stable identity behind handles. When + // both sides carry one, a foreign leaf is a different pane completing + // someone else's task — reject it; the same leaf is the same pane after + // a handle remint or tab break-out. Without pane data (older CLI, + // sessions without ORCA_PANE_KEY) payload IDs stay the completion + // authority. + if ( + dispatch.assignee_pane_key && + msg.sender_pane_key && + isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) + ) { + onLog( + `Warning: worker_done for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` + ) + return { action: 'ignored' } + } onLog( - `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}` + `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}; accepting payload provenance` ) - return { action: 'ignored' } } // Why: `orchestration.send` can release the DB lock before waking the // coordinator; the later coordinator read still needs to observe completion. @@ -143,7 +198,26 @@ 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 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/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/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts new file mode 100644 index 00000000000..2a75853b1be --- /dev/null +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -0,0 +1,517 @@ +/** + * Tests for the remote-desktop viewer width driver. + * + * A remote (relay/shared-control) desktop viewer takes the PTY width floor so + * the host's own fit cascade stops resizing the viewed PTY out from under it + * (the remote alt-screen "porridge"). Mirrors the mobile presence lock but + * suppresses only RESIZE, never input. Covers: + * - idle → remote-desktop on register; release to idle on last unregister + * - multi-viewer: driver survives until the last viewer detaches + * - a live mobile driver outranks a remote-desktop viewer + * - isPtyResizeDrivenRemotely gates host resize for mobile AND remote-desktop + * - PTY exit clears the remote-desktop registry + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type * as GitUsernameModule from '../git/git-username' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) +vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null) + } +}) +vi.mock('../git/git-username', async () => { + const actual = await vi.importActual('../git/git-username') + return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: 5_000 + }) +} + +function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs }) + }) + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + const resizeCalls: { ptyId: string; cols: number; rows: number }[] = [] + const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + resizeCalls.push({ ptyId, cols, rows }) + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + fitOverrideEvents.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: (ptyId, driver) => { + driverEvents.push({ ptyId, driver: { ...driver } }) + } + }) + return { + runtime, + driverEvents, + fitOverrideEvents, + resizeCalls + } +} + +describe('remote desktop viewer width driver', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('applying a viewport suppresses host resize without touching driver state', async () => { + const { runtime, driverEvents } = createRuntime() + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + // It is deliberately NOT a driver kind: the presence-lock state machine and + // its cross-layer driver-change notifications stay untouched. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents).toHaveLength(0) + }) + + it('sizes the PTY to the latest active desktop viewer', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + + // Activity on the narrower viewer transfers ownership to it. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 }) + + // Later activity on the wide viewer transfers ownership back. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 140, 50) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 }) + + // A passive peer leaving cannot disturb the active owner. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 }) + }) + + it('stops suppressing host resize only when the LAST viewer detaches', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // First viewer leaves — another remote viewer still holds the floor. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // Last viewer leaves — the host reclaims its own width (next pty:resize applies). + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('coexists with a mobile driver and outlives it (host stays suppressed)', async () => { + const { runtime, fitOverrideEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // A desktop viewer registering must NOT disturb the mobile driver. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // When the phone leaves, the surviving viewer keeps host resize suppressed + // (the registry is independent of the mobile driver state). + runtime.onClientDisconnected('phone-A') + vi.advanceTimersByTime(10_000) + expect(runtime.getDriver('pty-1').kind).not.toBe('mobile') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + expect(fitOverrideEvents.at(-1)).toMatchObject({ + ptyId: 'pty-1', + mode: 'remote-desktop-fit', + cols: 100, + rows: 40 + }) + + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('isPtyResizeDrivenRemotely is false for idle and desktop drivers', async () => { + const { runtime } = createRuntime() + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('does not let passive attachment or hydration steal host ownership', async () => { + const { runtime, resizeCalls } = createRuntime() + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24, false) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 30, false) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizeCalls).toHaveLength(0) + }) + + it('lets host activity automatically reclaim from a remote owner', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await runtime.claimRemoteDesktopHost('pty-1', 132, 42) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 132, rows: 42 }) + expect(runtime.getRemoteDesktopFitHold('pty-1', 'sub-A')).toMatchObject({ + mode: 'remote-desktop-fit', + cols: 132, + rows: 42 + }) + }) + + it('does not emit resize churn for repeated activity from the current owner', async () => { + const { runtime, resizeCalls, fitOverrideEvents } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + resizeCalls.splice(0) + fitOverrideEvents.splice(0) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + + expect(resizeCalls).toHaveLength(0) + expect(fitOverrideEvents).toHaveLength(0) + }) + + it('reclaims the host width when the last viewer detaches', async () => { + const { runtime } = createRuntime() + // The viewer drives the source PTY to its own 80-wide viewport. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + // While the viewer owns width, the blocked host fit records the host's + // latest reclaim geometry. + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + + // Detaching the last viewer must actively resize the PTY back to the host's + // OWN width (120), not the departed viewer's polluted 80. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 }) + }) + + it('does not retain a remote reclaim target when only a phone suppresses host resize', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + // pty:resize is suppressed for mobile too, but this measurement must not + // seed the separate remote-viewer cache when no desktop viewer exists. + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 35) + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('restores and consumes the host target when the last viewer leaves during phone-fit', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + // The host pane can change after remote ownership ends while phone-fit + // remains active; its trusted measurement becomes the deferred target. + runtime.recordRendererGeometry('pty-1', 140, 38) + + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 38 }) + + // A later viewer must capture the new host geometry, not reuse the prior + // session's already-consumed 140-column reclaim target. + runtime.onExternalPtyResize('pty-1', 130, 36) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 130, rows: 36 }) + }) + + it('preserves indefinite phone-fit after the last desktop viewer leaves', async () => { + const { runtime } = createRuntime(null) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' }) + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) + + it('does not let an older host reclaim consume a newer viewer cycle target', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + // Do not await the reclaim before the next viewer joins. The serialized + // host resize can finish after sub-B has established a newer viewer cycle. + const firstReclaim = runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + const secondAttach = runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 90, 30) + await Promise.all([firstReclaim, secondAttach]) + + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('does not coalesce queued claims from different desktop owners', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24, false) + const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map< + string, + { running: Promise; pending: { target: { ownerSubscriptionKey?: string } }[] } + > + layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] }) + + void runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 28) + void runtime.claimRemoteDesktopViewer('pty-1', 'sub-B') + + expect( + layoutQueues.get('pty-1')?.pending.map(({ target }) => target.ownerSubscriptionKey) + ).toEqual(['sub-A', 'sub-B']) + layoutQueues.delete('pty-1') + }) + + it('makes a host claim join a pending disconnect reclaim', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map< + string, + { running: Promise; pending: { waiters: unknown[] }[] } + > + layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] }) + + void runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + void runtime.claimRemoteDesktopHost('pty-1', 150, 40) + + expect(layoutQueues.get('pty-1')?.pending).toHaveLength(1) + expect(layoutQueues.get('pty-1')?.pending[0]?.waiters).toHaveLength(2) + layoutQueues.delete('pty-1') + }) + + it('removes same-PTY viewer floors with one bounded reclaim', async () => { + const { runtime, resizeCalls } = createRuntime() + const subscriptionKeys: string[] = [] + for (let index = 0; index < 100; index += 1) { + const key = `sub-${index}` + subscriptionKeys.push(key) + await runtime.updateRemoteDesktopViewer('pty-1', key, `viewer-${index}`, 100, 30) + } + resizeCalls.splice(0) + + await runtime.unregisterRemoteDesktopViewers('pty-1', subscriptionKeys) + + expect(resizeCalls).toEqual([{ ptyId: 'pty-1', cols: 150, rows: 40 }]) + }) + + it('applies the latest viewer width when the host takes back from a phone', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + }) + + it('refresh never creates a floor (one-shot viewport cannot leak host suppression)', async () => { + const { runtime } = createRuntime() + // A one-shot terminal.updateViewport (refresh) from a viewer with no stream + // floor must NOT register one — that floor would leak (nothing releases a + // one-shot RPC's state), pinning the host at a stale width forever. + const created = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 90, 30) + expect(created).toBe(false) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Once the client's STREAM registers the floor (and owns its cleanup), a + // refresh updates that same floor by clientId. + await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + const refreshed = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 70, 25) + expect(refreshed).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 }) + + // A refresh for a DIFFERENT client that owns no floor is a no-op. + const other = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-Z', 60, 20) + expect(other).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 }) + + // The stream cleanup releases the sole floor — the refresh left no orphan, + // so the host reclaims (suppression drops). + await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('captures host geometry when a passive stream is first claimed by terminal.send', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 30, false) + + await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 80, 24, true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1') + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('keeps the host reclaim target when the reclaim resize fails', async () => { + const { runtime } = createRuntime() + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + let resizeSucceeds = true + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + if (!resizeSucceeds) { + return false + } + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + + // A viewer drives the PTY to 80; while it owns width, the host reports its + // own 120-wide reclaim geometry. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + + // The last viewer leaves but the reclaim resize fails: the PTY is stuck at + // 80, so the recorded host target (120) MUST be retained for a later retry. + resizeSucceeds = false + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + + // A retry once resize works restores the TRUE host width (120), not the + // stale viewer width (80) that a dropped target would have resolved to. + resizeSucceeds = true + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 }) + }) + + it('supersedes a failed reclaim target when the host later resizes successfully', async () => { + const { runtime } = createRuntime() + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + let resizeSucceeds = true + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + if (!resizeSucceeds) { + return false + } + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + resizeSucceeds = false + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + // pty:resize has already resized the provider before this runtime mirror + // hook runs; that successful host geometry supersedes the retained 120. + resizeSucceeds = true + ptySizes.set('pty-1', { cols: 140, rows: 42 }) + runtime.onExternalPtyResize('pty-1', 140, 42) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 42 }) + }) + + it('PTY exit clears the remote-desktop registry', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + runtime.onPtyExit('pty-1', 0) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + + // A fresh viewer on the same id re-establishes suppression cleanly (no stale set). + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 100, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + }) +}) 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.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..d262890d8d7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -208,6 +208,7 @@ const UiUpdate = z _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(), @@ -238,6 +239,7 @@ const UiUpdate = z 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(), diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index e5255595a6a..6308b425784 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -90,6 +90,59 @@ 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('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() @@ -598,11 +651,11 @@ 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') insertWorkerDone({ - from: 'term_intruder', + from: 'term_reminted', taskId: task.id, dispatchId: dispatch.id }) @@ -613,8 +666,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 () => { @@ -670,6 +723,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 +744,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 +1088,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 +1156,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' }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index f22ad62d8f4..4232633f1dd 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({ @@ -195,14 +220,20 @@ 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: params.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 } + } } runtime.deliverPendingMessagesForHandle(params.to) runtime.notifyMessageArrived(params.to, msg.type) @@ -231,7 +262,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ type: params.type as MessageType, priority: params.priority as MessagePriority, threadId, - payload: params.payload + payload: params.payload, + senderPaneKey: params.senderPaneKey }) ) for (const message of messages) { @@ -264,15 +296,15 @@ 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) { + 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. @@ -406,7 +438,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } return base }) - return { tasks, count: tasks.length } + return { + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } } }), @@ -475,7 +510,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } } - 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 diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 73c77701c97..7a9b9e448a6 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -26,6 +26,13 @@ import { } from '../../../../shared/terminal-input' import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQuerySequence, + type TerminalReplyQueryScanState +} from '../../../../shared/terminal-reply-query-scan' import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS @@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -88,6 +96,14 @@ type TerminalMultiplexStream = { isMobile: boolean ackOutput: boolean ackInFlightBytes: number + supportsDesktopViewportClaims: boolean + desktopClaimTail: Promise + // Why: whether THIS stream registered a remote-desktop width driver, so + // detach only unregisters what it registered — a passive (viewport-less) + // stream sharing a client id must not release another stream's width floor. + registeredRemoteDesktopDriver: boolean + remoteDesktopSubscriptionKey: string + pendingRemoteDesktopViewport: { cols: number; rows: number } | null buffering: boolean ackPendingOutput: TerminalOutputFrameChunk[] ackPendingOutputBytes: number @@ -332,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, @@ -372,6 +413,29 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + function appendAckPendingOutput( stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk @@ -631,15 +695,39 @@ async function sendMobileResizeRestream( async function updateViewportForClient( runtime: OrcaRuntimeService, ptyId: string, + subscriptionKey: string, client: TerminalViewportClient, viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop' + defaultType: 'mobile' | 'desktop', + // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so + // it must only refresh a floor the client already owns via its stream (never + // create a leak-prone standalone one). Stream paths that own cleanup register. + registration: 'register' | 'refresh' = 'register', + claim = false ): Promise<{ updated: boolean; applied: boolean }> { const type = client.type ?? defaultType if (type === 'mobile') { return runtime.updateMobileViewport(ptyId, client.id, viewport) } - const updated = await runtime.updateDesktopViewport(ptyId, viewport) + // Why: stream attachment observes geometry without taking control. Only a + // later activity/claim frame may make this desktop authoritative. + const updated = + registration === 'refresh' + ? await runtime.refreshRemoteDesktopViewer( + ptyId, + client.id, + viewport.cols, + viewport.rows, + claim + ) + : await runtime.updateRemoteDesktopViewer( + ptyId, + subscriptionKey, + client.id, + viewport.cols, + viewport.rows, + claim + ) return { updated, applied: updated } } @@ -699,6 +787,9 @@ const TerminalSend = TerminalHandle.extend({ enter: z.unknown().optional(), interrupt: z.unknown().optional(), requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input bytes but are not a user + // action that should transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), // Why: identifies the caller for the driver state machine. Optional for // backward compatibility with older mobile clients (server falls back to // the most recent mobile actor when absent). New mobile builds populate @@ -709,7 +800,14 @@ const TerminalSend = TerminalHandle.extend({ id: requiredString('Missing client ID'), type: z.enum(['mobile', 'desktop']).default('desktop').optional() }) - .optional() + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() }) const TerminalViewport = z.object({ @@ -806,7 +904,8 @@ const TerminalSubscribe = TerminalHandle.extend({ viewport: TerminalViewport.optional(), capabilities: z .object({ - terminalBinaryStream: z.literal(1).optional() + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional() }) .optional() }) @@ -824,7 +923,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ viewport: TerminalViewport.optional(), capabilities: z .object({ - ackOutput: z.literal(1).optional() + ackOutput: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional() }) .optional() }) @@ -895,7 +995,8 @@ const TerminalUpdateViewport = TerminalHandle.extend({ viewport: z.object({ cols: z.number().int().min(20).max(240), rows: z.number().int().min(8).max(120) - }) + }), + claim: z.boolean().optional() }) // Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md). `null` @@ -983,13 +1084,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } // Why: guarded resolution — a stale handle must fail with // terminal_handle_stale (clients recover by re-deriving the handle) // instead of evaluating driver/lock state against the wrong PTY (#7718). const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { @@ -999,6 +1127,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + if ( + leaf?.ptyId && + params.client?.type === 'desktop' && + params.claimViewport === true && + params.viewport + ) { + const claim = await updateViewportForClient( + runtime, + leaf.ptyId, + `send:${params.client.id}`, + params.client, + params.viewport, + 'desktop', + 'refresh', + true + ) + // Why: a stream-less request has no lifecycle cleanup and cannot safely + // create ownership. Never write at stale geometry if no stream exists. + if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + } const hasText = typeof params.text === 'string' && params.text.length > 0 const hasSuffix = params.enter === true || params.interrupt === true if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { @@ -1017,10 +1173,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') } @@ -1096,7 +1264,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (leaf?.ptyId && mobileFloorClientId) { + if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } @@ -1280,9 +1448,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const viewportUpdate = await updateViewportForClient( runtime, leaf.ptyId, + `viewport:${params.client.id}`, params.client, params.viewport, - 'mobile' + 'mobile', + // Why: one-shot RPC with no disconnect hook — refresh the client's + // existing stream-owned floor only, never create a leak-prone one. + 'refresh', + params.claim === true ) return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } } @@ -1481,7 +1654,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) flushAllAckPendingOutput() } - const detachStream = (streamId: number, emitEnd: boolean): void => { + const detachStream = ( + streamId: number, + emitEnd: boolean, + releaseRemoteDesktopDriver = true + ): void => { const stream = streams.get(streamId) if (!stream) { return @@ -1506,6 +1683,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.exitWaiterAbort.abort() if (stream.isMobile && stream.client?.id) { runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } else if ( + releaseRemoteDesktopDriver && + stream.registeredRemoteDesktopDriver && + stream.client?.id + ) { + // Why: release the remote-desktop width floor so the host can reclaim + // its own width once the last remote viewer leaves — but only if THIS + // stream took it (a passive stream must not release a peer's floor). + runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) } if (emitEnd) { emit({ type: 'end', streamId }) @@ -1516,8 +1702,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } closed = true + const remoteDesktopKeysByPty = new Map() for (const streamId of Array.from(streams.keys())) { - detachStream(streamId, false) + const stream = streams.get(streamId) + if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { + const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] + keys.push(stream.remoteDesktopSubscriptionKey) + remoteDesktopKeysByPty.set(stream.ptyId, keys) + } + detachStream(streamId, false, false) + } + // Why: one connection can own many panes backed by the same PTY. + // Remove those floors together so close scans each PTY registry once. + for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { + void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) } unregisterControlHandler() resolveMultiplex() @@ -1550,8 +1748,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { return } - void runtime - .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + // Mobile already has the higher-priority floor; a rejected desktop + // viewport claim must never suppress later phone input. + const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + void inputClaimTail + .then((claimed) => + !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) + ? null + : runtime.sendTerminal(stream.terminal, { + text, + enter: false, + interrupt: false + }) + ) .then(async () => { if (stream.isMobile && stream.client?.id) { await runtime.mobileTookFloor(stream.ptyId, stream.client.id) @@ -1567,13 +1776,72 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { return } - void updateViewportForClient( - runtime, - stream.ptyId, - stream.client, - { cols: viewport.cols, rows: viewport.rows }, - stream.isMobile ? 'mobile' : 'desktop' - ).catch(() => {}) + const cols = viewport.cols + const rows = viewport.rows + // Why: resize registers stream-scoped geometry so detach can release + // it. Older clients lack explicit claims, so Resize remains control. + if (!stream.isMobile && stream.client?.id) { + stream.registeredRemoteDesktopDriver = true + if (stream.buffering) { + stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + stream.desktopClaimTail = stream.desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!, + { cols, rows }, + stream.isMobile ? 'mobile' : 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + return stream.supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + stream.client && + !stream.isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + stream.registeredRemoteDesktopDriver = true + stream.desktopClaimTail = stream.desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + ) + .catch(() => false) return } if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { @@ -1669,6 +1937,28 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false stream.outputBatcher.flush() + // Why: a viewer resize that arrived during the snapshot buffering + // window is parked in pendingRemoteDesktopViewport; apply it now or + // it is silently dropped until the viewer's next resize. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } } } } @@ -1730,6 +2020,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ isMobile, ackOutput: request.capabilities?.ackOutput === 1, ackInFlightBytes: 0, + supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, + desktopClaimTail: Promise.resolve(true), + registeredRemoteDesktopDriver: false, + // Why: streamId is client-local, so two remote connections can both + // use stream 1 for the same PTY. Scope the width-floor key by + // connectionId (guaranteed present above) so they can't + // overwrite/release each other's floor. + remoteDesktopSubscriptionKey: `multiplex:${connectionId}:${request.streamId}`, + pendingRemoteDesktopViewport: null, buffering: true, ackPendingOutput: [], ackPendingOutputBytes: 0, @@ -1788,13 +2087,29 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.viewport && request.client) { + } else if (request.client?.id && request.viewport) { + // Why: subscribe records this stream's geometry and cleanup key, + // but does not claim ownership. Activity frames claim later. + stream.registeredRemoteDesktopDriver = true + stream.pendingRemoteDesktopViewport = request.viewport + } + if ( + !isMobile && + request.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null await updateViewportForClient( runtime, ptyId, + stream.remoteDesktopSubscriptionKey, request.client, - request.viewport, - 'desktop' + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims ) } if (closed || streams.get(request.streamId) !== stream) { @@ -1803,10 +2118,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!isMobile) { stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) + .mode ?? 'desktop-fit') emit({ type: 'fit-override-changed', streamId: request.streamId, - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) @@ -1849,12 +2169,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const snapshotOutputSeq = serialized?.seq if (!isMobile) { const fitOverride = runtime.getTerminalFitOverride(ptyId) + const desktopHold = runtime.getRemoteDesktopFitHold?.( + ptyId, + stream.remoteDesktopSubscriptionKey + ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } emit({ type: 'fit-override-changed', streamId: request.streamId, - mode: fitOverride?.mode ?? 'desktop-fit', - cols: fitOverride?.cols ?? size?.cols ?? 0, - rows: fitOverride?.rows ?? size?.rows ?? 0 + mode: fitOverride?.mode ?? desktopHold.mode, + cols: fitOverride?.cols ?? desktopHold.cols, + rows: fitOverride?.rows ?? desktopHold.rows }) emit({ type: 'driver-changed', @@ -1906,7 +2230,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false stream.outputBatcher.flush() - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { stream.outputBatcher.flush() const resizeGeneration = stream.resizeGeneration + 1 @@ -1955,6 +2278,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } sendResizedFrame(stream, event) }) + // Install the resize listener before draining the parked viewport; + // applyLayout emits synchronously and the stream must observe it. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } void runtime .waitForTerminal(request.terminal, { condition: 'exit', @@ -2047,40 +2391,86 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + // Why: only unregister the width floor this subscription took (see the + // multiplex stream's registeredRemoteDesktopDriver note). + let registeredRemoteDesktopDriver = false if (!useBinaryStream) { - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - // Why: legacy JSON streams register cleanup after snapshot awaits; if - // the socket closed meanwhile, registering now would orphan listeners. - if (signal?.aborted) { - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq - }) - // Why: desktop can have both a hidden automation watcher and a visible // pane subscribed to the same terminal. Key by client when provided so // one stream cannot evict the other. const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - await new Promise((resolve) => { - const outputBatcher = createTerminalOutputBatcher((chunk) => { + const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` + let closed = false + let outputBatcher: ReturnType | null = null + let unsubscribeData = (): void => {} + let unsubscribeFit = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register before viewport/snapshot awaits so a socket close cannot + // orphan either the stream listeners or its remote-desktop width floor. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + try { + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (closed || signal?.aborted) { + runtime.cleanupSubscription(subscriptionId) + return + } + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (closed || signal?.aborted) { + runtime.cleanupSubscription(subscriptionId) + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher.push(data) + outputBatcher?.push(data) }) // Why: this legacy JSON stream can feed a live xterm view too // (older web/desktop subscribers), so it conservatively registers @@ -2088,52 +2478,56 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // a withheld model reply — the pre-Phase-5 status quo — which is // strictly safer than a double reply under a view consumer. const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - const unsubscribeData = (): void => { + unsubscribeData = () => { releaseViewSubscriber() unsubscribeStreamData() } - const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher.flush() + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') emit({ type: 'fit-override-changed', - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) }) - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - outputBatcher.flush() - outputBatcher.dispose() - unsubscribeData() - unsubscribeFit() - emit({ type: 'end' }) - resolve() - }, - connectionId - ) // Why: bind the exit-waiter to the connection dispatch signal so it is // removed on socket close/error instead of leaking until real exit. void runtime .waitForTerminal(params.terminal, { condition: 'exit', signal }) .then(() => runtime.cleanupSubscription(subscriptionId)) .catch(() => runtime.cleanupSubscription(subscriptionId)) - }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } return } const streamId = nextTerminalStreamId++ + const remoteDesktopSubscriptionKey = `stream:${streamId}` let cursor = 0 let closed = false let buffering = true + let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null // Why: the cols the mobile client last rewrapped to; gate the // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 let pendingOutput: TerminalOutputChunk[] = [] + let desktopClaimTail: Promise = Promise.resolve(true) let pendingOutputBytes = 0 let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -2159,6 +2553,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unregisterBinaryHandler() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) + } else if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) } emit({ type: 'end' }) resolveStream() @@ -2206,8 +2602,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void runtime - .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) + void desktopClaimTail + .then((claimed) => + !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) + ? null + : runtime.sendTerminal(params.terminal, { + text, + enter: false, + interrupt: false + }) + ) .then(async () => { if (isMobile && clientId) { await runtime.mobileTookFloor(ptyId, clientId) @@ -2227,54 +2631,142 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) { return } - void updateViewportForClient( - runtime, - ptyId, - params.client, - { cols: viewport.cols, rows: viewport.rows }, - 'desktop' - ).catch(() => {}) + const cols = viewport.cols + const rows = viewport.rows + if (clientId) { + registeredRemoteDesktopDriver = true + if (buffering) { + pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + desktopClaimTail = desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client!, + { cols, rows }, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + return supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + params.client && + clientId && + !isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if ( + !viewport || + typeof viewport.cols !== 'number' || + typeof viewport.rows !== 'number' + ) { + return + } + const cols = viewport.cols + const rows = viewport.rows + registeredRemoteDesktopDriver = true + desktopClaimTail = desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + ) + .catch(() => false) } }) ?? (() => {}) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: live bytes must be captured before mobile fit awaits. Registering + // mobile presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } else if (clientId && params.viewport) { + // Why: legacy subscribe records geometry without taking ownership; + // only an explicit activity/claim frame may suppress the host. + registeredRemoteDesktopDriver = true + pendingRemoteDesktopViewport = params.viewport } if (closed) { return } - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: binary subscribe streams feed remote xterm views (mobile and - // binary-capable desktop clients) that answer queries with view - // authority; the main model responder yields while attached. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { @@ -2305,7 +2797,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq const snapshotFrameSeq = serialized?.seq ?? layoutSeq - const snapshotOutputSeq = serialized?.seq + // Why: recovery snapshots advance output coverage past the initial + // snapshot seq; query replay and boundary trims must track the seq + // that actually covered the buffered chunks or a query absorbed by a + // recovery snapshot gets zero replies. + let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', streamId, @@ -2392,12 +2888,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) pendingOutput = trimmed.chunks pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq } buffering = false const bufferedOutput = pendingOutput.splice(0) + const queryReplayData = pendingQueryOverflowed + ? '' + : pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: serialized snapshots omit control queries, yet their output seq + // can trim the live chunk. Replay only the query after snapshot so the + // mobile xterm answers once while ordinary output stays deduplicated. + outputBatcher.push(queryReplayData) + } if (!initialOutputOverflowed) { for (const item of bufferedOutput) { - const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + pendingQuerySequences + ) + } if (uncoveredData) { outputBatcher.push(uncoveredData, item.meta) } @@ -2405,7 +2932,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() - const sendResizedFrame = (event: { cols: number rows: number @@ -2465,12 +2991,39 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendResizedFrame(event) }) + // Install the resize listener before draining the parked viewport; + // applyLayout emits synchronously and the stream must observe it. + if ( + clientId && + params.client && + registeredRemoteDesktopDriver && + pendingRemoteDesktopViewport + ) { + const viewport = pendingRemoteDesktopViewport + pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ).catch(() => {}) + } + // Legacy fit-override-changed for non-mobile (desktop) subscribers unsubscribeFit = !isMobile ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') emit({ type: 'fit-override-changed', - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) 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/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 9cdc88a021b..77deacc420c 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -315,6 +315,9 @@ describe('RpcDispatcher streaming', () => { ) await vi.waitFor(() => expect(cleanups.has('terminal-1:desktop-1')).toBe(true)) + // Cleanup now registers before snapshot work so a disconnect cannot orphan + // a desktop width floor; wait for the actual exit waiter before resolving it. + await vi.waitFor(() => expect(runtime.waitForTerminal).toHaveBeenCalled()) resolveExit() await dispatchPromise diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 62dcfddc5ab..20b608c94c2 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -26,6 +26,12 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // only stub the legacy resolveLeafForHandle still bind; tests that need a // null/stale leaf override this explicitly. resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true), + isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false), + getRemoteDesktopFitHold: vi.fn().mockReturnValue({ mode: 'desktop-fit', cols: 120, rows: 40 }), + isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false), ...overrides } as OrcaRuntimeService } @@ -82,8 +88,7 @@ describe('terminal multiplex RPC', () => { cleanup?.() }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), - updateDesktopViewport: vi.fn().mockResolvedValue(true) + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) }) const dispatcher = new RpcDispatcher({ runtime, @@ -120,7 +125,8 @@ describe('terminal multiplex RPC', () => { streamId: 5, terminal: 'terminal-1', client: { id: 'desktop-1', type: 'desktop' }, - viewport: { cols: 300, rows: 150 } + viewport: { cols: 300, rows: 150 }, + capabilities: { desktopViewportClaims: 1 } }) }) )! @@ -143,12 +149,134 @@ describe('terminal multiplex RPC', () => { }) ]) ) - expect(runtime.updateDesktopViewport).toHaveBeenCalledWith('pty-1', { - cols: 300, - rows: 150 - }) + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 300, + 150, + false + ) expect(handlers.has(5)).toBe(true) + let releaseClaim = (): void => {} + vi.mocked(runtime.updateRemoteDesktopViewer).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + ) + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ClaimViewport, + streamId: 5, + seq: 0, + payload: encodeTerminalStreamJson({ cols: 96, rows: 32 }) + }) + )! + ) + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + true + ) + ) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 1, + payload: encodeTerminalStreamJson({ cols: 96, rows: 32 }) + }) + )! + ) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.updateRemoteDesktopViewer).not.toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + false + ) + releaseClaim() + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + false + ) + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length + vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) + for (const [opcode, seq, payload] of [ + [TerminalStreamOpcode.ClaimViewport, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Resize, 4, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Input, 5, encodeTerminalStreamText('blocked')] + ] as const) { + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload }) + )! + ) + } + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 88, + 28, + false + ) + ) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(sentAfterSuccessfulClaim) + for (const [opcode, seq, payload] of [ + [TerminalStreamOpcode.ClaimViewport, 6, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Resize, 7, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Input, 8, encodeTerminalStreamText('retry')] + ] as const) { + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload }) + )! + ) + } + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { + text: 'retry', + enter: false, + interrupt: false + }) + ) + dataListenerRef.current?.('a') dataListenerRef.current?.('b') await vi.runOnlyPendingTimersAsync() @@ -160,24 +288,6 @@ describe('terminal multiplex RPC', () => { expect(outputFrames[0]?.streamId).toBe(5) expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') - handlers.get(5)?.( - decodeTerminalStreamFrame( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - streamId: 5, - seq: 2, - payload: encodeTerminalStreamText('ls\r') - }) - )! - ) - await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) - ) - handlers.get(5)?.( decodeTerminalStreamFrame( encodeTerminalStreamFrame({ @@ -189,10 +299,14 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.updateDesktopViewport).toHaveBeenLastCalledWith('pty-1', { - cols: 100, - rows: 30 - }) + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 100, + 30, + false + ) ) const snapshotStartFrame = binaryFrames @@ -248,13 +362,318 @@ describe('terminal multiplex RPC', () => { .join('') ).toBe('snapshot') + // A viewport-less stream is passive: it must neither register nor later + // release the active stream's width floor when the connection closes. + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 5, + payload: encodeTerminalStreamJson({ + streamId: 6, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + messages.some( + (msg) => + JSON.parse(msg).result?.type === 'subscribed' && + JSON.parse(msg).result?.streamId === 6 + ) + ).toBe(true) + ) + + // A second active floor on the same PTY is released in the same batch, + // keeping connection teardown to one registry recomputation per PTY. + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 6, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'desktop-2', type: 'desktop' }, + viewport: { cols: 90, rows: 30 } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + messages.some( + (msg) => + JSON.parse(msg).result?.type === 'subscribed' && + JSON.parse(msg).result?.streamId === 7 + ) + ).toBe(true) + ) + runtime.cleanupSubscription('terminal-multiplex:conn-1') await dispatchPromise + expect(runtime.unregisterRemoteDesktopViewer).not.toHaveBeenCalled() + expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledTimes(1) + expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledWith('pty-1', [ + 'multiplex:conn-1:5', + 'multiplex:conn-1:7' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('applies a viewer resize parked during a snapshot-request buffering window', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-snap', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 300, rows: 150 }, + capabilities: { desktopViewportClaims: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + // Ignore the subscribe-time floor registration; assert only the drained one. + vi.mocked(runtime.updateRemoteDesktopViewer).mockClear() + + // A snapshot request opens the buffering window synchronously (buffering + // is set before the first await inside the handler)... + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 9, + seq: 2, + payload: encodeTerminalStreamJson({ requestId: 3, scrollbackRows: 1000 }) + }) + )! + ) + // ...so a resize arriving now is PARKED, not applied inline. + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 9, + seq: 3, + payload: encodeTerminalStreamJson({ cols: 88, rows: 33 }) + }) + )! + ) + expect(runtime.updateRemoteDesktopViewer).not.toHaveBeenCalled() + + // Once the snapshot completes and buffering clears, the parked resize is + // drained (previously it was silently dropped until the next resize). + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + 'multiplex:conn-snap:9', + 'desktop-1', + 88, + 33, + false + ) + ) + + runtime.cleanupSubscription('terminal-multiplex:conn-snap') + await dispatchPromise } finally { vi.useRealTimers() } }) + it('emits a resize drained after the initial snapshot', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let resolveSnapshot = (_value: { data: string; cols: number; rows: number }): void => {} + let resizeListener: + | ((event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void) + | undefined + const updateRemoteDesktopViewer = vi.fn( + async (_ptyId: string, _key: string, _clientId: string, cols: number, rows: number) => { + if (updateRemoteDesktopViewer.mock.calls.length > 1) { + resizeListener?.({ cols, rows, displayMode: 'desktop', reason: 'apply-layout', seq: 2 }) + } + return true + } + ) + const runtime = stubRuntime({ + updateRemoteDesktopViewer, + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn((_ptyId, listener) => { + resizeListener = listener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-initial-resize', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 80, rows: 24 } + }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 9, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 132, rows: 43 }) + }) + )! + ) + expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(1) + + resolveSnapshot({ data: 'snapshot', cols: 80, rows: 24 }) + await vi.waitFor(() => + expect( + binaryFrames.some( + (bytes) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Resized + ) + ).toBe(true) + ) + const opcodes = binaryFrames.map((bytes) => decodeTerminalStreamFrame(bytes)?.opcode) + expect(opcodes.indexOf(TerminalStreamOpcode.Resized)).toBeGreaterThan( + opcodes.indexOf(TerminalStreamOpcode.SnapshotEnd) + ) + + runtime.cleanupSubscription('terminal-multiplex:conn-initial-resize') + await dispatchPromise + }) + it('drops stale mobile resize re-stream completions for multiplex streams', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -305,7 +724,7 @@ describe('terminal multiplex RPC', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})), sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), - updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) + updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ runtime, @@ -346,6 +765,34 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => expect(resizeListener).toBeDefined()) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 90, rows: 24 }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.updateMobileViewport).toHaveBeenCalled()) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 3, + payload: encodeTerminalStreamText('x') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'x', + enter: false, + interrupt: false + }) + ) binaryFrames.splice(0) resizeListener?.({ @@ -1712,6 +2159,58 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('owns and releases a viewport floor for legacy JSON desktop streams', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-json-1', type: 'desktop' }, + viewport: { cols: 88, rows: 30 } + }), + (msg) => messages.push(msg), + { connectionId: 'conn-json-1' } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'scrollback')).toBe(true) + ) + const subscriptionKey = vi.mocked(runtime.updateRemoteDesktopViewer).mock.calls[0]?.[1] + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + expect.stringMatching(/^json:/), + 'desktop-json-1', + 88, + 30, + true + ) + + runtime.cleanupSubscription('terminal-1:desktop-json-1') + await dispatchPromise + expect(runtime.unregisterRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', subscriptionKey) + }) + it('bounds oversized live output frames for subscribed binary streams', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index c426513c9f5..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -4,6 +4,10 @@ import type { RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import { TERMINAL_METHODS } from './methods/terminal' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' +import { + RUNTIME_CAPABILITIES, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR @@ -132,6 +136,46 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) + it('awaits a desktop viewport claim before acknowledged input', async () => { + let releaseClaim = (): void => {} + const refreshRemoteDesktopViewer = vi.fn( + () => + new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + ) + const sendTerminal = vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + refreshRemoteDesktopViewer, + sendTerminal + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x03', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 96, rows: 32 }, + claimViewport: true + }) + ) + + await vi.waitFor(() => + expect(refreshRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', 'desktop-1', 96, 32, true) + ) + expect(sendTerminal).not.toHaveBeenCalled() + releaseClaim() + await expect(response).resolves.toMatchObject({ ok: true }) + expect(sendTerminal).toHaveBeenCalled() + }) + it('accepts legacy clientless mobile input when the current driver is mobile', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -169,6 +213,148 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') }) + it('writes a validated terminal query reply without taking the mobile floor', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: '\x1b[3;4R', enter: false, interrupt: false }, + { beforeWrite: undefined } + ) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts a query reply from only the elected mobile subscriber', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + isMobileTerminalQueryReplyAuthority: vi.fn( + (_ptyId: string, clientId: string) => clientId === 'mobile-1' + ), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const makeReply = (clientId: string) => + dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: clientId, type: 'mobile' } + }) + ) + const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')]) + + expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } }) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(1) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('rejects ordinary input that claims to be a terminal query reply', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'a', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it.each([ + ['enter', { enter: true }], + ['interrupt', { interrupt: true }], + ['agent guard', { requireAgentStatus: 'sendable' }] + ])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' }, + ...extra + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('rejects query replies that spoof a different authenticated mobile client', async () => { + const replies: string[] = [] + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'spoofed-mobile', type: 'mobile' } + }), + (reply) => replies.push(reply), + { clientId: 'authenticated-mobile' } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('rejects oversized terminal send text before runtime dispatch', async () => { const secret = 'terminal-send-secret' const runtime = stubRuntime({ @@ -339,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' }), @@ -435,4 +699,9 @@ describe('terminal send RPC', () => { expect(response.result).toEqual({ restored: true }) expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') }) + // Why: mobile gates reply forwarding on this static capability; removing it + // would silently re-break mobile query answering against current hosts. + it('advertises query-reply input support to mobile clients', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) + }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index ba921c6b14e..4a674f9df91 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -21,9 +21,12 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe } as OrcaRuntimeService } -function makeRequest(method: string, params?: unknown): RpcRequest { - return { id: 'req-1', authToken: 'tok', method, params } -} +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) describe('terminal subscribe buffering', () => { it('settles mobile subscribe waits when the stream signal aborts before PTY spawn', async () => { @@ -43,10 +46,7 @@ describe('terminal subscribe buffering', () => { ), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }) }) - const dispatcher = new RpcDispatcher({ - runtime, - methods: TERMINAL_METHODS - }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => { } }) + it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveMobileSubscribe: () => void = () => {} + const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn()) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve) => { + resolveMobileSubscribe = () => resolve(true) + }) + ), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(dataListener).toBeDefined() + expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1') + dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 }) + resolveMobileSubscribe() + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + ).toBe(true) + ) + const queryOutputFrames = binaryFrames.filter((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + expect(queryOutputFrames).toHaveLength(1) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const runtime = stubRuntime({ @@ -89,10 +178,7 @@ describe('terminal subscribe buffering', () => { limited: true }) }) - const dispatcher = new RpcDispatcher({ - runtime, - methods: TERMINAL_METHODS - }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) await dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1' }), @@ -214,7 +300,14 @@ describe('terminal subscribe buffering', () => { expect(await outcomePromise).toBe('settled') expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() - expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + // Cleanup must exist before snapshot work so an abort cannot orphan a + // desktop width floor, but the abort must consume it before listeners start. + expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith( + 'terminal-1:desktop-1', + expect.any(Function), + 'conn-legacy-json' + ) + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('terminal-1:desktop-1') expect(runtime.waitForTerminal).not.toHaveBeenCalled() expect(messages).toEqual([]) } finally { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 02e69c6dc97..4bfa7259dab 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -64,7 +64,7 @@ function createMockStore() { reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { reassignments.push({ oldTargetId, newTargetId }) // Pretend one repo referenced the old id. - return 1 + return ['repo-1'] }) } } @@ -458,6 +458,39 @@ describe('SshConnectionStore', () => { ) expect(result).toHaveLength(1) }) + + it('reports every exact repo migration from a multi-host re-import', () => { + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old-a', + configHost: 'host-a', + host: 'host-a.example.com', + port: 22, + username: '', + label: 'host-a', + removedAt: 1 + }) + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old-b', + configHost: 'host-b', + host: 'host-b.example.com', + port: 22, + username: '', + label: 'host-b', + removedAt: 1 + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'host-a' }, { host: 'host-b' }]) + sshConfigHostsToTargetsMock.mockReturnValue([ + candidate({ configHost: 'host-a' }), + candidate({ configHost: 'host-b' }) + ]) + + sshStore.importFromSshConfig({ reAdopt: true }) + + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: 'ssh-old-a', newTargetId: 'tmp-host-a', repoIds: ['repo-1'] }, + { oldTargetId: 'ssh-old-b', newTargetId: 'tmp-host-b', repoIds: ['repo-1'] } + ]) + }) }) describe('re-adoption of orphaned workspaces', () => { @@ -520,8 +553,9 @@ describe('SshConnectionStore', () => { const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0] expect(oldId).toBe('ssh-old') expect(newId).toMatch(/^ssh-/) - // Re-adoption count surfaces so the IPC layer can refresh the repo list. - expect(sshStore.lastReadoptedRepoCount).toBe(1) + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: newId, repoIds: ['repo-1'] } + ]) }) // Why: drive the real remove→re-add path so the tombstone carries the @@ -549,7 +583,9 @@ describe('SshConnectionStore', () => { }) expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id) - expect(sshStore.lastReadoptedRepoCount).toBe(1) + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: added.id, newTargetId: readded.id, repoIds: ['repo-1'] } + ]) }) // A different account on the SAME host must NOT re-adopt, even though both @@ -592,7 +628,7 @@ describe('SshConnectionStore', () => { }) expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() - expect(sshStore.lastReadoptedRepoCount).toBe(0) + expect(sshStore.lastRepoReadoptions).toEqual([]) }) }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index df3e6cd8fee..295e7d57ac3 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -1,5 +1,5 @@ import type { Store } from '../persistence' -import type { SshTarget } from '../../shared/ssh-types' +import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' import { @@ -44,14 +44,13 @@ export class SshConnectionStore { this.store.addSshTarget(full) // Why: re-adopt workspaces that were orphaned when the same host was removed // (repos/worktrees still point at the old, now-dead target id). Track the - // count so the IPC layer knows whether to refresh the repo list. - this.lastReadoptedRepoCount = readoptOrphanedWorkspacesForTarget(this.store, full) + // exact migrations so IPC can refresh and renderer can prune only proven stale rows. + this.lastRepoReadoptions = readoptOrphanedWorkspacesForTarget(this.store, full) return full } - /** Repos re-adopted by the most recent addTarget/importFromSshConfig call. - * Lets the IPC layer broadcast repos:changed only when workspaces reattached. */ - lastReadoptedRepoCount = 0 + /** Exact migrations from the most recent add/import operation. */ + lastRepoReadoptions: SshRepoReadoption[] = [] upsertRuntimeOwnedTarget( runtimeId: string, @@ -120,7 +119,7 @@ export class SshConnectionStore { * manual targets. Returns the inserted and updated targets. */ importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { - let readoptedThisImport = 0 + const readoptions: SshRepoReadoption[] = [] // Why: the explicit Import action re-adopts every config host, so it clears // all tombstones first. The passive on-open sync passes no flag and keeps // deleted hosts suppressed. @@ -212,12 +211,12 @@ export class SshConnectionStore { // Why: a freshly-inserted config host may be one the user removed and is // now re-importing — re-adopt its orphaned workspaces. Updated-in-place // targets keep their id, so their repos were never orphaned. - readoptedThisImport += readoptOrphanedWorkspacesForTarget(this.store, inserted) + readoptions.push(...readoptOrphanedWorkspacesForTarget(this.store, inserted)) changed.push(inserted) } } - this.lastReadoptedRepoCount = readoptedThisImport + this.lastRepoReadoptions = readoptions return changed } } 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-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index c426e616fed..a29801c3632 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -98,7 +98,7 @@ describe('cross-version isolation', () => { // $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec), // npm install, finalize, socket probe, socket poll, then GC scan. const responses: string[] = [ - 'Linux x86_64', // uname -sm + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) '', // mkdir -p remoteRelayDir (v2) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index ec825cdc4b6..33467819035 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,7 +42,7 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), - execCommand: vi.fn().mockResolvedValue('Linux x86_64') + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) vi.mock('./ssh-remote-node-resolution', () => ({ @@ -123,7 +123,7 @@ describe('deployAndLaunchRelay', () => { it('calls exec to detect remote platform', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -131,13 +131,16 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(mockExecCommand).toHaveBeenCalledWith(conn, 'uname -sm') + expect(mockExecCommand).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('reports progress via callback', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -153,7 +156,7 @@ describe('deployAndLaunchRelay', () => { it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -167,7 +170,7 @@ describe('deployAndLaunchRelay', () => { it('resolves node concurrently with remote home, not after the install-state chain', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let markNodeResolutionStarted: () => void = () => {} const nodeResolutionStarted = new Promise((resolve) => { @@ -221,7 +224,7 @@ describe('deployAndLaunchRelay', () => { const conn = makeMockConnection() vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeProbeStarted: () => void = () => {} const remoteHomeProbeStartedPromise = new Promise((resolve) => { @@ -274,7 +277,7 @@ describe('deployAndLaunchRelay', () => { fallbackInstallStateCompleted = true return true }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME @@ -302,7 +305,7 @@ describe('deployAndLaunchRelay', () => { throw sessionLimitError }) .mockResolvedValueOnce(true) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe @@ -324,7 +327,7 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) const nodeError = new Error('Node.js not found on remote host') vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) @@ -346,7 +349,7 @@ describe('deployAndLaunchRelay', () => { }) }) }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME const timedDeploy = Promise.race([ @@ -370,7 +373,7 @@ describe('deployAndLaunchRelay', () => { const installError = new Error('permission denied while checking relay install') vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) @@ -384,7 +387,7 @@ describe('deployAndLaunchRelay', () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeSettled = false mockExecCommand.mockReturnValueOnce( @@ -420,7 +423,7 @@ describe('deployAndLaunchRelay', () => { it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -439,7 +442,7 @@ describe('deployAndLaunchRelay', () => { it('allows an unlimited SSH disconnect grace window', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -458,7 +461,7 @@ describe('deployAndLaunchRelay', () => { it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -477,7 +480,7 @@ describe('deployAndLaunchRelay', () => { it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -522,12 +525,12 @@ describe('deployAndLaunchRelay', () => { const connB = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) mockExecCommand - .mockResolvedValueOnce('Linux x86_64') // uname A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe A .mockResolvedValueOnce('/home/user') // $HOME A .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe A .mockResolvedValueOnce('DEAD') // probe A .mockResolvedValueOnce('READY') // poll A - .mockResolvedValueOnce('Linux x86_64') // uname B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe B .mockResolvedValueOnce('/home/user') // $HOME B .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe B .mockResolvedValueOnce('DEAD') // probe B @@ -561,8 +564,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe @@ -607,8 +610,8 @@ describe('deployAndLaunchRelay', () => { onClose: vi.fn() }) mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe yet @@ -654,8 +657,8 @@ describe('deployAndLaunchRelay', () => { const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker @@ -682,8 +685,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname A - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe A @@ -691,8 +694,8 @@ describe('deployAndLaunchRelay', () => { .mockResolvedValueOnce('') .mockResolvedValueOnce('READY') .mockResolvedValueOnce('') // persist active pipe A - .mockRejectedValueOnce(new Error('uname not found')) // uname B - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe B diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 2b63b3a2d50..4d2f92eb947 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -152,7 +152,7 @@ function makeExecResponses(opts: { // build toolchain — no chmod/probe/launch slots are reached. if (opts.npmInstall !== 'ok') { return [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -171,7 +171,7 @@ function makeExecResponses(opts: { ? { reject: 'cd: no such file or directory' } : opts.probe const slots: ExecResponse[] = [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -530,7 +530,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') const conn = makeMockConnection(sftpCapture) feed([ - 'Windows AMD64', + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', 'C:\\Users\\u', '', // mkdir remoteDir '', // npm install native deps @@ -605,7 +605,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) feed([ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', // first native-deps probe before lock 'MISSING', // re-probe after lock @@ -632,7 +632,13 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { it('does not mutate an existing relay dir when required native deps are present', async () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) - feed(['Linux x86_64', '/home/u', 'ORCA-NATIVE-DEPS-OK', 'DEAD', 'READY']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) await deployAndLaunchRelay(conn) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 5176d06411f..05b0068cade 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -674,8 +674,26 @@ export class SshRelaySession { const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined) 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) diff --git a/src/main/ssh/ssh-remote-platform-detection.test.ts b/src/main/ssh/ssh-remote-platform-detection.test.ts index a8ffd416830..71fc4e7c0cd 100644 --- a/src/main/ssh/ssh-remote-platform-detection.test.ts +++ b/src/main/ssh/ssh-remote-platform-detection.test.ts @@ -11,13 +11,18 @@ const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detecti const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' +} + describe('detectRemoteHostPlatform', () => { beforeEach(() => { execCommandMock.mockReset() }) it('detects POSIX hosts from uname output', async () => { - execCommandMock.mockResolvedValueOnce('Linux x86_64\n') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'linux-x64', @@ -25,13 +30,16 @@ describe('detectRemoteHostPlatform', () => { arch: 'x64', pathFlavor: 'posix' }) - expect(execCommandMock).toHaveBeenCalledWith(conn, 'uname -sm') + expect(execCommandMock).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('falls back to PowerShell detection for Windows remotes', async () => { execCommandMock .mockRejectedValueOnce(new Error('uname unavailable')) - .mockResolvedValueOnce('Windows AMD64\r\n') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64\r\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -45,17 +53,50 @@ describe('detectRemoteHostPlatform', () => { expect.stringContaining('powershell.exe'), { wrapCommand: false } ) + const command = execCommandMock.mock.calls[1]?.[1] ?? '' + expect(decodePowerShellCommand(command)).toContain( + 'Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)' + ) + }) + + it('ignores untagged platforms before the tagged Windows ARM64 result', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('uname unavailable')) + .mockResolvedValueOnce( + 'Linux x86_64\r\nWindows AMD64\r\n#< CLIXML\r\n' + + '__ORCA_REMOTE_PLATFORM__ Windows ARM64\r\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'win32-arm64', + os: 'win32', + arch: 'arm64', + pathFlavor: 'windows' + }) + }) + + it('ignores a marker concatenated to unterminated startup noise', async () => { + execCommandMock.mockResolvedValueOnce( + 'startup noise__ORCA_REMOTE_PLATFORM__ Linux x86_64\n' + + '__ORCA_REMOTE_PLATFORM__ Linux arm64\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'linux-arm64' + }) }) it('returns null when neither probe yields a supported platform', async () => { - execCommandMock.mockResolvedValueOnce('Linux').mockResolvedValueOnce('FreeBSD x86_64') + execCommandMock + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ FreeBSD x86_64') await expect(detectRemoteHostPlatform(conn)).resolves.toBeNull() }) it('does not use whitespace regex splitting for remote platform output', async () => { const splitSpy = vi.spyOn(String.prototype, 'split') - execCommandMock.mockResolvedValueOnce('Darwin arm64 extra') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64 extra') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64' diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f31f53f4ec4..f72b5384335 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -1,10 +1,15 @@ import type { SshConnection } from './ssh-connection' -import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../../shared/process-output-field-scanner' import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' +const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' + export async function detectRemoteHostPlatform( conn: SshConnection ): Promise { @@ -18,12 +23,12 @@ export async function detectRemoteHostPlatform( async function detectUnamePlatform(conn: SshConnection): Promise { try { - const output = await execCommand(conn, 'uname -sm') - const parts = getProcessOutputFields(output, 2) - if (parts.length < 2) { - return null - } - return parseUnameToRelayPlatform(parts[0], parts[1]) + const output = await execCommand( + conn, + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + ) + return parseRemotePlatformOutput(output) } catch { return null } @@ -35,15 +40,27 @@ async function detectWindowsPlatform(conn: SshConnection): Promise { describe('detectRemoteHostPlatform', () => { it('uses uname when the remote is POSIX', async () => { - vi.mocked(execCommand).mockResolvedValueOnce('Darwin arm64') + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64', @@ -46,7 +46,7 @@ describe('detectRemoteHostPlatform', () => { it('falls back to PowerShell when uname is unavailable on Windows', async () => { vi.mocked(execCommand) .mockRejectedValueOnce(new Error('uname not recognized')) - .mockResolvedValueOnce('Windows AMD64') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -59,5 +59,6 @@ describe('detectRemoteHostPlatform', () => { expect(script).toContain('$arch = $env:PROCESSOR_ARCHITECTURE') expect(script).toContain('try { $runtimeArch =') expect(script).toContain('catch {}') + expect(script).toContain('Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)') }) }) 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-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-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index f263fe01d88..2b066a0bdc4 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -28,7 +28,7 @@ function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) { }, reassignSshTargetId: (oldTargetId: string, newTargetId: string) => { reassigned.push({ oldId: oldTargetId, newId: newTargetId }) - return 1 + return ['repo-1'] } } as unknown as Store return { store, reassigned, remaining: () => current } @@ -49,40 +49,44 @@ const tombstone = ( describe('readoptOrphanedWorkspacesForTarget', () => { it('re-adopts on matching configHost alias', () => { const fake = makeFakeStore([tombstone({ configHost: 'devbox', host: 'changed.example.com' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'devbox', host: 'now.example.com' }) ) - expect(count).toBe(1) + expect(readoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ]) expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) expect(fake.remaining()).toHaveLength(0) // tombstone consumed }) it('re-adopts on matching host+user+port when no alias', () => { const fake = makeFakeStore([tombstone()]) - const count = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) - expect(count).toBe(1) + const readoptions = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) + expect(readoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ]) expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) }) it('does not re-adopt when identity differs', () => { const fake = makeFakeStore([tombstone({ host: 'other.example.com', username: 'root' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) expect(fake.remaining()).toHaveLength(1) // tombstone left for a future match }) it('matches host/user/port case-insensitively', () => { const fake = makeFakeStore([tombstone({ host: 'Dev.Example.COM', username: 'Tim' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) it('does not match alias against a different host tuple', () => { @@ -90,16 +94,16 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) }) it('is a no-op when there are no tombstones', () => { const fake = makeFakeStore([]) - expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toBe(0) + expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toEqual([]) }) it('does NOT re-adopt across two different aliases that share host+user+port', () => { @@ -108,11 +112,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'prod-deploy', host: 'prod.example.com', username: 'deploy' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'prod-admin', host: 'prod.example.com', username: 'deploy' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) expect(fake.remaining()).toHaveLength(1) // tombstone preserved }) @@ -129,7 +133,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'dev.example.com', @@ -138,7 +142,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'bob' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) }) @@ -151,7 +155,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'dev.example.com', @@ -160,7 +164,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) it('still re-adopts via tuple when the re-added target has no alias', () => { @@ -169,11 +173,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: undefined, host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) }) diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts index 06b0cefd8ec..321bbdb5c0a 100644 --- a/src/main/ssh/ssh-target-readoption.ts +++ b/src/main/ssh/ssh-target-readoption.ts @@ -1,5 +1,9 @@ import type { Store } from '../persistence' -import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' +import type { + RemovedSshTargetTombstone, + SshRepoReadoption, + SshTarget +} from '../../shared/ssh-types' /** * Re-adoption of workspaces orphaned when an SSH target was removed. @@ -56,14 +60,18 @@ function tombstoneMatches(tombstone: RemovedSshTargetTombstone, target: Identity /** * Re-point orphaned repos/worktrees onto `newTarget` if a removed target with * the same host identity is tombstoned. Consumes the matching tombstone(s). - * Returns the number of repos re-adopted (0 when there was nothing to adopt). + * Returns exact repo/target migrations so the renderer can discard only rows + * proven to be stale after its per-host catalog merge. */ -export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshTarget): number { +export function readoptOrphanedWorkspacesForTarget( + store: Store, + newTarget: SshTarget +): SshRepoReadoption[] { const tombstones = store.getRemovedSshTargetTombstones() if (tombstones.length === 0) { - return 0 + return [] } - let readopted = 0 + const readoptions: SshRepoReadoption[] = [] for (const tombstone of tombstones) { // Why: a re-added target can't share the id of one that still exists, but // guard anyway so we never re-point a live target onto itself. @@ -74,12 +82,15 @@ export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshT if (!tombstoneMatches(tombstone, newTarget)) { continue } - readopted += store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + const repoIds = store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + if (repoIds.length > 0) { + readoptions.push({ oldTargetId: tombstone.oldTargetId, newTargetId: newTarget.id, repoIds }) + } // Consume the tombstone whether or not it re-pointed anything: the host has // returned, so the record has served its purpose. store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) } - return readopted + return readoptions } /** Build a tombstone from a target about to be removed. */ 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/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/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/win32-utils-async-acl.test.ts b/src/main/win32-utils-async-acl.test.ts new file mode 100644 index 00000000000..22c9ff992c8 --- /dev/null +++ b/src/main/win32-utils-async-acl.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock +})) + +type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void + +const originalUsername = process.env.USERNAME + +beforeEach(() => { + vi.resetModules() + execFileMock.mockReset() + execFileSyncMock.mockReset() +}) + +afterEach(() => { + if (originalUsername === undefined) { + delete process.env.USERNAME + } else { + process.env.USERNAME = originalUsername + } +}) + +describe('grantDirAclAsync', () => { + it('keeps icacls off the synchronous main-process path', async () => { + process.env.USERNAME = 'alice' + let complete: ExecCallback | undefined + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + complete = callback + return {} as never + } + ) + const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils') + + const pending = grantDirAclAsync('C:\\Users\\alice\\Orca') + await Promise.resolve() + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(execFileMock).toHaveBeenCalledWith( + getIcaclsExePath(), + ['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'], + { encoding: 'utf-8', windowsHide: true, timeout: 10_000 }, + expect.any(Function) + ) + complete?.(null, '', '') + await expect(pending).resolves.toBeUndefined() + }) + + it('resolves a missing username through asynchronous whoami before icacls', async () => { + delete process.env.USERNAME + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') + + await grantDirAclAsync('C:\\Orca') + + expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([ + getWhoamiExePath(), + ['/user', '/fo', 'csv', '/nh'], + { encoding: 'utf-8', windowsHide: true, timeout: 5000 } + ]) + expect(execFileMock.mock.calls[1]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-123:(OI)(CI)(F)' + ]) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('does not overwrite a concurrent cached identity with a late async result', async () => { + delete process.env.USERNAME + let completeWhoami: ExecCallback | undefined + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + completeWhoami = callback + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n') + const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils') + + const pending = grantDirAclAsync('C:\\Orca') + await Promise.resolve() + expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456') + completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '') + await pending + + expect(execFileMock.mock.calls[1]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-456:(OI)(CI)(F)' + ]) + }) + + it('retries identity resolution after a transient whoami failure', async () => { + delete process.env.USERNAME + execFileMock + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(new Error('whoami timed out'), '', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, '', '') + return {} as never + } + ) + const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') + + await grantDirAclAsync('C:\\Orca') + await grantDirAclAsync('C:\\Orca') + + expect( + execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath()) + ).toHaveLength(2) + expect(execFileMock.mock.calls[2]?.[1]).toEqual([ + 'C:\\Orca', + '/grant:r', + '*S-1-5-21-123:(OI)(CI)(F)' + ]) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index a4b8dae1286..264ed6e6604 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -1,7 +1,23 @@ -import { execFileSync } from 'node:child_process' +import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process' import { delimiter, join } from 'node:path' import { existsSync } from 'node:fs' +function execFileWithoutBlocking( + command: string, + args: string[], + options: ExecFileOptionsWithStringEncoding +): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, options, (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + }) + }) +} + /** * Full path to icacls.exe. Electron's main process may have a stripped PATH * that excludes System32, causing bare `icacls` to throw ENOENT. @@ -10,6 +26,11 @@ export function getIcaclsExePath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe` } +/** Absolute path because service-launched Electron can omit System32 from PATH. */ +export function getWhoamiExePath(): string { + return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe` +} + /** * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). @@ -78,13 +99,10 @@ export function isPermissionError(error: unknown): boolean { // and hardened envs where USERNAME is unset. Fall back to the SID via // `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative // and always available on Windows. Cached because it never changes in-process. -let cachedIdentity: string | null | undefined +let cachedIdentity: string | undefined +let pendingIdentityResolution: Promise | null = null -export function resolveCurrentWindowsIdentity(): string | null { - return resolveCurrentIdentity() -} - -function resolveCurrentIdentity(): string | null { +function cachedOrEnvironmentIdentity(): string | undefined { if (cachedIdentity !== undefined) { return cachedIdentity } @@ -92,20 +110,75 @@ function resolveCurrentIdentity(): string | null { cachedIdentity = process.env.USERNAME return cachedIdentity } + return undefined +} + +function identityFromWhoamiOutput(output: string): string | null { + // CSV columns: "DOMAIN\\user","S-1-5-21-..." + const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim()) + return sidMatch ? `*${sidMatch[1]}` : null +} + +export function resolveCurrentWindowsIdentity(): string | null { + return resolveCurrentIdentity() +} + +function resolveCurrentIdentity(): string | null { + const knownIdentity = cachedOrEnvironmentIdentity() + if (knownIdentity !== undefined) { + return knownIdentity + } try { - const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], { + const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 - }).trim() - // CSV columns: "DOMAIN\\user","S-1-5-21-..." - const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output) - cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null + }) + const resolvedIdentity = identityFromWhoamiOutput(output) + if (resolvedIdentity) { + cachedIdentity = resolvedIdentity + } + return resolvedIdentity } catch { - cachedIdentity = null + return null } - return cachedIdentity +} + +async function resolveCurrentIdentityAsync(): Promise { + const knownIdentity = cachedOrEnvironmentIdentity() + if (knownIdentity !== undefined) { + return knownIdentity + } + if (!pendingIdentityResolution) { + pendingIdentityResolution = (async () => { + try { + const stdout = await execFileWithoutBlocking( + getWhoamiExePath(), + ['/user', '/fo', 'csv', '/nh'], + { + encoding: 'utf-8', + windowsHide: true, + timeout: 5000 + } + ) + // Why: a synchronous caller may resolve identity while async whoami + // is in flight; its authoritative cached result must win the race. + const resolvedIdentity = identityFromWhoamiOutput(stdout) + if (cachedIdentity === undefined && resolvedIdentity) { + cachedIdentity = resolvedIdentity + } + return cachedIdentity ?? resolvedIdentity + } catch { + // Why: transient service/PATH failures must not permanently disable + // ACL repair for every later crash attempt in this process. + return cachedIdentity ?? null + } + })().finally(() => { + pendingIdentityResolution = null + }) + } + return pendingIdentityResolution } /** @@ -138,6 +211,24 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): }) } +export async function grantDirAclAsync(dirPath: string): Promise { + const identity = await resolveCurrentIdentityAsync() + if (!identity) { + return + } + // Why: crash recovery runs on Electron's main thread; an asynchronous + // icacls child keeps its worst-case timeout from freezing every window. + await execFileWithoutBlocking( + getIcaclsExePath(), + [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`], + { + encoding: 'utf-8', + windowsHide: true, + timeout: 10_000 + } + ) +} + /** * Resolve spawn parameters for a command that may be a Windows batch script. * diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index dc278cb1ffa..54b4728f7cd 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -62,6 +62,7 @@ vi.mock('../browser/browser-manager', () => ({ import { createMainWindow, loadMainWindow } from './createMainWindow' import { ipcMain } from 'electron' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -2389,7 +2390,7 @@ describe('createMainWindow', () => { expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142) }) - it('passes the renderer webContents id through crash classification callbacks', () => { + it('passes the renderer webContents id through crash recording and recovery callbacks', () => { vi.useFakeTimers() const windowHandlers: Record void> = {} @@ -2422,13 +2423,11 @@ describe('createMainWindow', () => { return browserWindowInstance }) const onRendererProcessGone = vi.fn() - const shouldRecordRendererCrash = vi.fn(() => true) const shouldRecoverRenderer = vi.fn(() => true) try { createMainWindow(null, { onRendererProcessGone, - shouldRecordRendererCrash, shouldRecoverRenderer }) @@ -2436,7 +2435,6 @@ describe('createMainWindow', () => { windowHandlers['render-process-gone']?.({} as never, details) vi.advanceTimersByTime(250) - expect(shouldRecordRendererCrash).toHaveBeenCalledWith(details, 424) expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424) expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424) } finally { @@ -2444,9 +2442,10 @@ describe('createMainWindow', () => { } }) - it('does not notify the crash recorder for an expected renderer teardown', () => { + it('forwards expected renderer teardowns so the recorder can diagnose suppression', () => { const windowHandlers: Record void> = {} const webContents = { + id: 142, on: vi.fn((event, handler) => { windowHandlers[event] = handler }), @@ -2475,10 +2474,7 @@ describe('createMainWindow', () => { }) const onRendererProcessGone = vi.fn() - createMainWindow(null, { - onRendererProcessGone, - shouldRecordRendererCrash: () => false - }) + createMainWindow(null, { onRendererProcessGone }) windowHandlers['render-process-gone']?.( {} as never, @@ -2488,7 +2484,10 @@ describe('createMainWindow', () => { } as Electron.RenderProcessGoneDetails ) - expect(onRendererProcessGone).not.toHaveBeenCalled() + expect(onRendererProcessGone).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'killed', exitCode: 15 }), + expect.any(Number) + ) consoleError.mockRestore() }) @@ -2703,6 +2702,48 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) + it('bounds renderer launch-failed recovery with the crash-loop breaker', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const onRendererRecoveryExhausted = vi.fn() + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + try { + createMainWindow(null, { + onRendererRecoveryExhausted, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: 'none' + }) + }) + + const details = { + reason: 'launch-failed', + exitCode: 18 + } as Electron.RenderProcessGoneDetails + const driveLaunchFailure = (): void => { + windowHandlers['render-process-gone']?.({} as never, details) + vi.advanceTimersByTime(250) + } + + driveLaunchFailure() + driveLaunchFailure() + driveLaunchFailure() + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) + + driveLaunchFailure() + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ details, recentRecoveryCount: 3 }) + ) + } finally { + consoleError.mockRestore() + } + }) + function createStartupRevealWindowFixture() { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 1a30178a4e2..ff66b5ebc16 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -111,13 +111,6 @@ type CreateMainWindowOptions = { details: Electron.RenderProcessGoneDetails, webContentsId: number ) => void - /** Returns true when a renderer loss should be reported as a crash. Why: - * intentional reload/update/quit paths can emit crash-like `killed` - * renderer exits, but surfacing those as crash reports is noise. */ - shouldRecordRendererCrash?: ( - details: Electron.RenderProcessGoneDetails, - webContentsId: number - ) => boolean /** Returns true when Orca should reload after an unexpected renderer loss. * Why: update relaunch and app quit intentionally tear down child * processes; recovering those paths can fight Electron's shutdown. */ @@ -713,10 +706,9 @@ export function createMainWindow( resetShortcutRecorderFocus() // Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL // after a confirmed close; that is window lifecycle noise, not a crash. - if ( - !windowClosing && - opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false - ) { + if (!windowClosing) { + // Why: the recorder owns crash classification and durable suppression + // diagnostics; filtering here made expected-teardown evidence unreachable. opts?.onRendererProcessGone?.(details, rendererWebContentsId) } if (!windowClosing) { diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 42add57be66..8653c6866eb 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/types' import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' -import { getDefaultBaseRef } from './git/repo' +import { getBaseRefDefault } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' @@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase( const resolvedBaseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path), + resolveDefaultBaseRef: () => getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( repo.path, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ceb0d6a8d54..4babcd587bb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,12 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { @@ -83,6 +89,7 @@ import type { GitStatusResult, GitUpstreamStatus, GitHubAssignableUser, + GitHubCreateIssueResult, GitHubPRFile, GitHubPRFileContents, GitHubPrStartPoint, @@ -309,6 +316,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' import type { CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, CrashReportRecord, CrashReportSubmitArgs, CrashReportSubmitResult, @@ -375,6 +383,8 @@ import type { import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, SshTarget, PortForwardEntry, EnrichedDetectedPort @@ -1231,6 +1241,7 @@ export type PreloadApi = { telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ id: string + launchAgent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig snapshot?: string snapshotCols?: number @@ -1245,6 +1256,7 @@ export type PreloadApi = { write: (id: string, data: string) => void writeAccepted: (id: string, data: string) => Promise resize: (id: string, cols: number, rows: number) => void + claimViewport: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void clearBuffer: (id: string) => void @@ -1280,6 +1292,7 @@ export type PreloadApi = { publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> @@ -1388,10 +1401,9 @@ export type PreloadApi = { ) => Promise recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void submit: (args: CrashReportSubmitArgs) => Promise - copyLatestDiagnostics: (args?: { - reportId?: string - notes?: string - }) => Promise<{ ok: true } | { ok: false; error: string }> + copyLatestDiagnostics: ( + args?: CrashReportCopyDiagnosticsArgs + ) => Promise<{ ok: true } | { ok: false; error: string }> } export: ExportApi gh: { @@ -1480,7 +1492,7 @@ export type PreloadApi = { body: string labels?: string[] assignees?: string[] - }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + }) => Promise countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise listWorkItems: (args: { repoPath: string @@ -2349,7 +2361,18 @@ export type PreloadApi = { readFile: (args: { filePath: string connectionId?: string - }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> + includeLocalLogMetadata?: boolean + }) => Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> + readLocalLogTail: (args: LocalLogTailReadArgs) => Promise + startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise + stopLocalLogTail: (args: { subscriptionId: string }) => Promise + onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void downloadFile: (args: { filePath: string connectionId: string @@ -2887,7 +2910,7 @@ export type PreloadApi = { getStatus: () => Promise call: (args: { method: string; params?: unknown }) => Promise> getTerminalFitOverrides: () => Promise< - { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > getTerminalDrivers: () => Promise< { @@ -2906,7 +2929,7 @@ export type PreloadApi = { onTerminalFitOverrideChanged: ( callback: (event: { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void @@ -2980,13 +3003,13 @@ export type PreloadApi = { // Removed-target id → last known label, for showing a friendly host name on // workspaces still pinned to a target that no longer exists. listRemovedTargetLabels: () => Promise> - addTarget: (args: { target: Omit }) => Promise + addTarget: (args: { target: Omit }) => Promise updateTarget: (args: { id: string updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: (args?: { reAdopt?: boolean }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 2e960b9c461..3c7af55f0af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import type { GitHubPRRefreshReason, GitHubAssignableUser, GitHubCommentResult, + GitHubCreateIssueResult, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -129,6 +130,8 @@ import { } from '../shared/rich-markdown-context-menu' import type { SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, SshTarget, PortForwardEntry, EnrichedDetectedPort @@ -195,6 +198,12 @@ import { type NativeFileDropPayload, type NativeFileDropPathEntry } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' @@ -205,6 +214,7 @@ import type { } from '../shared/localhost-worktree-labels' import type { CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, CrashReportSubmitArgs, CrashReportSubmitResult, ReactErrorBoundaryReportArgs, @@ -839,6 +849,9 @@ const api = { resize: (id: string, cols: number, rows: number): void => { ipcRenderer.send('pty:resize', { id, cols, rows }) }, + claimViewport: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + }, /** Why: measurement-only sibling of resize. Fires when a desktop pane * container measures real geometry (e.g. previously hidden tab becomes @@ -986,6 +999,8 @@ const api = { /** Return the PTY foreground process basename when available (e.g. "codex"). */ getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }), + confirmForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), /** Resolve the live cwd of a PTY via `/proc` (Linux) or `lsof` (macOS). * Returns `''` when the id is unknown or the platform cannot resolve one. */ @@ -1139,7 +1154,7 @@ const api = { ipcRenderer.send('crashReports:recordBreadcrumb', args), submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), - copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => + copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) }, @@ -1254,8 +1269,7 @@ const api = { body: string labels?: string[] assignees?: string[] - }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:createIssue', args), + }): Promise => ipcRenderer.invoke('gh:createIssue', args), countWorkItems: (args: { repoPath: string @@ -1443,10 +1457,9 @@ const api = { sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), - // Why: every renderer subscribes to local mutation broadcasts so each - // window's work-item-details cache invalidates the affected entry. The - // event fires after a successful mutation in any window — see - // src/main/ipc/github.ts broadcastWorkItemMutated. + // Why: the app renderer owns the work-item-details cache. Main targets this + // bridge for non-origin mutations; origin callers already updated their + // cache optimistically — see src/main/ipc/github.ts. onWorkItemMutated: ( callback: (payload: { repoPath: string @@ -2719,8 +2732,30 @@ const api = { readFile: (args: { filePath: string connectionId?: string - }): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => - ipcRenderer.invoke('fs:readFile', args), + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, downloadFile: (args: { filePath: string connectionId: string @@ -3849,7 +3884,7 @@ const api = { call: (args: { method: string; params?: unknown }): Promise> => ipcRenderer.invoke('runtime:call', args), getTerminalFitOverrides: (): Promise< - { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), getTerminalDrivers: (): Promise< { @@ -3870,14 +3905,19 @@ const api = { onTerminalFitOverrideChanged: ( callback: (event: { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number } + data: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } ) => callback(data) ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) @@ -3998,7 +4038,7 @@ const api = { listRemovedTargetLabels: (): Promise> => ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - addTarget: (args: { target: Omit }): Promise => + addTarget: (args: { target: Omit }): Promise => ipcRenderer.invoke('ssh:addTarget', args), updateTarget: (args: { @@ -4009,7 +4049,7 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (args?: { reAdopt?: boolean }): Promise => + importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => diff --git a/src/relay/git-handler-submodule-cache-invalidation.test.ts b/src/relay/git-handler-submodule-cache-invalidation.test.ts new file mode 100644 index 00000000000..57b6a5d2276 --- /dev/null +++ b/src/relay/git-handler-submodule-cache-invalidation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +type GitHandlerSpies = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + gitBuffer(args: string[], cwd: string): Promise + spawnClone( + args: string[], + cwd: string, + progressId: string + ): Promise<{ stdout: string; stderr: string }> +} + +describe('GitHandler submodule cache invalidation', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + let target: GitHandlerSpies + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + target = handler as unknown as GitHandlerSpies + vi.spyOn(target, 'git').mockResolvedValue({ stdout: '', stderr: '' }) + vi.spyOn(target, 'gitBuffer').mockResolvedValue(Buffer.from('content\n')) + }) + + afterEach(() => { + handler.dispose() + }) + + it.each([ + { + name: 'clone', + mutate: async () => { + vi.spyOn(target, 'spawnClone').mockResolvedValue({ stdout: '', stderr: '' }) + await dispatcher.callRequest('git.clone', { + args: ['clone', '--', 'https://example.com/repo.git', 'repo'], + cwd: '/projects', + progressId: 'clone-test' + }) + } + }, + { + name: 'mutating git.exec', + mutate: () => + dispatcher.callRequest('git.exec', { + args: ['commit', '--allow-empty', '-m', 'initialize'], + cwd: '/repo' + }) + } + ])('clears a cached empty .gitmodules result around $name', async ({ mutate }) => { + const diffRequest = { + worktreePath: '/repo', + filePath: 'src/file.ts', + staged: false + } + + await dispatcher.callRequest('git.diff', diffRequest) + await mutate() + await dispatcher.callRequest('git.diff', diffRequest) + + const gitSpy = vi.mocked(target.git) + const submodulePathReads = gitSpy.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(submodulePathReads).toHaveLength(2) + }) +}) diff --git a/src/relay/git-handler-submodule-ops.test.ts b/src/relay/git-handler-submodule-ops.test.ts index ed80b5bc3e7..15481097688 100644 --- a/src/relay/git-handler-submodule-ops.test.ts +++ b/src/relay/git-handler-submodule-ops.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import * as path from 'node:path' import type { GitExec } from './git-handler-ops' import { + MAX_SUBMODULE_PATHS_CACHE_ENTRIES, SUBMODULE_PATHS_CACHE_TTL_MS, + clearSubmodulePathsCache, createSubmodulePathsCache, + getSubmodulePathsCacheCount, listSubmodulePathsCached, resolveSubmoduleWorktreePath } from './git-handler-submodule-ops' @@ -56,6 +59,18 @@ describe('listSubmodulePathsCached', () => { expect(calls()).toBe(2) }) + it('prunes expired entries when a different remote worktree misses', async () => { + const { git } = gitmodulesExec(['vendor/lib']) + const cache = createSubmodulePathsCache() + + await listSubmodulePathsCached(git, '/repo-a', cache, 0) + await listSubmodulePathsCached(git, '/repo-b', cache, 0) + expect(getSubmodulePathsCacheCount(cache)).toBe(2) + + await listSubmodulePathsCached(git, '/repo-c', cache, SUBMODULE_PATHS_CACHE_TTL_MS + 1) + expect(getSubmodulePathsCacheCount(cache)).toBe(1) + }) + it('caches an empty result so a submodule-free repo is not re-read', async () => { let calls = 0 const git: GitExec = async () => { @@ -71,6 +86,67 @@ describe('listSubmodulePathsCached', () => { expect(second).toEqual([]) expect(calls).toBe(1) }) + + it('stays bounded through prolonged remote-worktree churn and retains recent entries', async () => { + const { git, calls } = gitmodulesExec(['vendor/lib']) + const cache = createSubmodulePathsCache() + let now = 0 + + for (let wave = 0; wave < 4; wave += 1) { + for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES; i += 1) { + await listSubmodulePathsCached(git, `/wave-${wave}-repo-${i}`, cache, now) + } + expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) + now += SUBMODULE_PATHS_CACHE_TTL_MS + 1 + } + + await listSubmodulePathsCached(git, '/retained-repo', cache, now) + for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1; i += 1) { + await listSubmodulePathsCached(git, `/final-repo-${i}`, cache, now) + } + await listSubmodulePathsCached(git, '/retained-repo', cache, now) + await listSubmodulePathsCached( + git, + `/final-repo-${MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1}`, + cache, + now + ) + await listSubmodulePathsCached(git, '/overflow-repo', cache, now) + + expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES) + const callsBeforeReads = calls() + await listSubmodulePathsCached(git, '/retained-repo', cache, now) + expect(calls()).toBe(callsBeforeReads) + await listSubmodulePathsCached(git, '/final-repo-0', cache, now) + expect(calls()).toBe(callsBeforeReads + 1) + }) + + it('does not let a pre-mutation SSH read repopulate the cache', async () => { + let resolveOldRead: ((value: { stdout: string; stderr: string }) => void) | undefined + let calls = 0 + const git: GitExec = () => { + calls += 1 + if (calls > 1) { + return Promise.resolve({ stdout: 'submodule.lib.path fresh-lib\n', stderr: '' }) + } + return new Promise((resolve) => { + resolveOldRead = resolve + }) + } + const cache = createSubmodulePathsCache() + + const oldRead = listSubmodulePathsCached(git, '/repo', cache, 1_000) + expect(resolveOldRead).toBeTypeOf('function') + clearSubmodulePathsCache(cache) + resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n', stderr: '' }) + + await expect(oldRead).resolves.toEqual(['old-lib']) + expect(getSubmodulePathsCacheCount(cache)).toBe(0) + await expect(listSubmodulePathsCached(git, '/repo', cache, 1_001)).resolves.toEqual([ + 'fresh-lib' + ]) + expect(calls).toBe(2) + }) }) describe('resolveSubmoduleWorktreePath', () => { diff --git a/src/relay/git-handler-submodule-ops.ts b/src/relay/git-handler-submodule-ops.ts index 9403fa96fcb..0409ba61177 100644 --- a/src/relay/git-handler-submodule-ops.ts +++ b/src/relay/git-handler-submodule-ops.ts @@ -19,11 +19,69 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-o * doesn't re-read `.gitmodules` over the (possibly high-latency) SSH link. */ export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 +export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } -export type SubmodulePathsCache = Map +export type SubmodulePathsCache = { + entries: Map + generation: number +} export function createSubmodulePathsCache(): SubmodulePathsCache { - return new Map() + return { entries: new Map(), generation: 0 } +} + +export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void { + cache.entries.clear() + // Why: a pre-mutation SSH read must not restore stale .gitmodules paths + // after the mutation invalidated them. + cache.generation += 1 +} + +export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number { + return cache.entries.size +} + +function getCachedSubmodulePaths( + cache: SubmodulePathsCache, + worktreePath: string, + now: number +): string[] | null { + const cached = cache.entries.get(worktreePath) + if (!cached) { + return null + } + if (cached.expiresAt <= now) { + cache.entries.delete(worktreePath) + return null + } + cache.entries.delete(worktreePath) + cache.entries.set(worktreePath, cached) + return cached.paths +} + +function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void { + for (const [worktreePath, entry] of cache.entries) { + if (entry.expiresAt <= now) { + cache.entries.delete(worktreePath) + } + } +} + +function rememberSubmodulePaths( + cache: SubmodulePathsCache, + worktreePath: string, + paths: string[], + now: number +): void { + cache.entries.delete(worktreePath) + cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { + const oldestPath = cache.entries.keys().next().value + if (oldestPath === undefined) { + break + } + cache.entries.delete(oldestPath) + } } /** @@ -38,12 +96,18 @@ export async function listSubmodulePathsCached( cache: SubmodulePathsCache, now: number = Date.now() ): Promise { - const cached = cache.get(worktreePath) - if (cached && cached.expiresAt > now) { - return cached.paths + const cached = getCachedSubmodulePaths(cache, worktreePath, now) + if (cached) { + return cached } + // Why: prune on misses so disconnected worktrees cannot accumulate while + // repeated SSH diff clicks keep their O(1) cache-hit path. + pruneExpiredSubmodulePaths(cache, now) + const cacheGeneration = cache.generation const paths = await listSubmodulePaths(git, worktreePath) - cache.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + if (cacheGeneration === cache.generation) { + rememberSubmodulePaths(cache, worktreePath, paths, now) + } return paths } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index ba8f631d815..1ae68d3ce88 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1193,6 +1193,10 @@ describe('GitHandler', () => { expect(gitBufferSpy).toHaveBeenCalledTimes(2) expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir) + const submodulePathReads = gitSpy.mock.calls.filter( + ([args]) => args[0] === 'config' && args.includes('.gitmodules') + ) + expect(submodulePathReads).toHaveLength(2) }) it('clears pending git.diff reads when a narrow ref fetch runs', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 67e78821d9a..d21dcf3cce9 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -22,6 +22,7 @@ import { buildSubmoduleInnerCommitRangeDiff, computeSubmodulePointerDiff, computeSubmoduleRangeEntries, + clearSubmodulePathsCache, createSubmodulePathsCache, findContainingSubmodule, listSubmodulePathsCached, @@ -39,6 +40,7 @@ import { } from './git-handler-worktree-ops' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' +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' @@ -182,6 +184,7 @@ export class GitHandler { dispose(): void { this.responseStreams.disposeAll() + this.clearGitMutationReadCaches() } private registerHandlers(): void { @@ -272,14 +275,19 @@ export class GitHandler { return this.responseStreams.startStream(payload, this.dispatcher, context) } - private async runWithDiffDedupeClear(run: () => Promise): Promise { - // Why: git mutations can stale both existing and concurrently-started diff reads. - // Clear before and after so later reads never join pre-mutation work. + private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + clearSubmodulePathsCache(this.submodulePathsCache) + } + + private async runWithGitReadCacheClear(run: () => Promise): Promise { + // Why: git mutations can stale existing and concurrently-started diff and + // .gitmodules reads. Clear before and after so later reads cannot join them. + this.clearGitMutationReadCaches() try { return await run() } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } @@ -476,42 +484,42 @@ export class GitHandler { } private async stage(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { await this.git(['add', '--', filePath], worktreePath) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async commit( params: Record ): Promise<{ success: boolean; error?: string }> { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const message = params.message as string try { return await commitChangesRelay(this.git.bind(this), worktreePath, message) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async unstage(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { await this.git(['restore', '--staged', '--', filePath], worktreePath) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async bulkStage(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { @@ -520,12 +528,12 @@ export class GitHandler { await this.git(['add', '--', ...chunk], worktreePath) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async bulkUnstage(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { @@ -534,32 +542,32 @@ export class GitHandler { await this.git(['restore', '--staged', '--', ...chunk], worktreePath) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async abortMerge(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string try { await this.git(['merge', '--abort'], worktreePath) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async abortRebase(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string try { await this.git(['rebase', '--abort'], worktreePath) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async checkout(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const branch = params.branch as string // Defense-in-depth: reject option-like branch tokens (the RPC schema also @@ -573,7 +581,7 @@ export class GitHandler { await this.git(['checkout', branch, '--'], worktreePath) return { ok: true as const, branch } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } @@ -632,7 +640,7 @@ export class GitHandler { } private async discard(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { @@ -661,12 +669,12 @@ export class GitHandler { this.cleanUntrackedPaths(worktreePath, [targetPath]) ) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async bulkDiscard(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] if (filePaths.length === 0) { @@ -720,7 +728,7 @@ export class GitHandler { } ) } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } @@ -826,7 +834,7 @@ export class GitHandler { } private async fetch(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string try { try { @@ -845,12 +853,12 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async forkSync(params: Record, context?: RequestContext) { - return this.runWithDiffDedupeClear(async () => { + return this.runWithGitReadCacheClear(async () => { const worktreePath = params.worktreePath as string const expectedUpstream = validateGitForkSyncExpectedUpstream(params.expectedUpstream, { required: true @@ -882,7 +890,7 @@ export class GitHandler { } private async fetchRemoteTrackingRef(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const remote = params.remote const branch = params.branch @@ -930,12 +938,12 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async fetchGitLabMergeRequestHead(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const remote = params.remote const mrIid = params.mrIid @@ -970,12 +978,12 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async push(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string // Why: mirror src/main/git/remote.ts. Push to a configured upstream when // present so SSH worktrees with non-origin targets do not get repointed. @@ -1000,12 +1008,12 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'push')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } private async pullWithArgs(params: Record, pullArgs: string[]) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string try { try { @@ -1036,7 +1044,7 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } @@ -1051,7 +1059,7 @@ export class GitHandler { } private async rebaseFromBase(params: Record) { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string try { @@ -1065,7 +1073,7 @@ export class GitHandler { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } finally { - this.gitDiffReadDedupe.clear() + this.clearGitMutationReadCaches() } } @@ -1128,7 +1136,10 @@ export class GitHandler { const cwd = params.cwd as string validateGitExecArgs(args) - const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) + const run = () => this.git(args, cwd, { signal: context?.signal }) + const { stdout, stderr } = gitExecMutatesRepository(args) + ? await this.runWithGitReadCacheClear(run) + : await run() return this.maybeStreamResponse({ stdout, stderr }, params, context) } @@ -1143,7 +1154,9 @@ export class GitHandler { if (args[0] !== 'clone') { throw new Error('git.clone only supports clone commands.') } - return await this.spawnClone(args, cwd, progressId, context) + return await this.runWithGitReadCacheClear(() => + this.spawnClone(args, cwd, progressId, context) + ) } private async spawnClone( @@ -1213,7 +1226,7 @@ export class GitHandler { } private async renameCurrentBranch(params: Record) { - return this.runWithDiffDedupeClear(async () => { + return this.runWithGitReadCacheClear(async () => { const worktreePath = params.worktreePath const newBranch = params.newBranch if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') { @@ -1250,7 +1263,7 @@ export class GitHandler { if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) { throw new Error('Invalid preserved branch force-delete request.') } - return this.runWithDiffDedupeClear(() => + return this.runWithGitReadCacheClear(() => forceDeletePreservedRelayBranch(this.git.bind(this), repoPath, branchName, expectedHead) ) } @@ -1359,11 +1372,11 @@ export class GitHandler { } private async addWorktree(params: Record) { - return this.runWithDiffDedupeClear(() => addWorktreeOp(this.git.bind(this), params)) + return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params)) } private async removeWorktree(params: Record) { - return this.runWithDiffDedupeClear(() => + return this.runWithGitReadCacheClear(() => removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) ) } @@ -1373,7 +1386,7 @@ export class GitHandler { } private async refreshLocalBaseRefForWorktreeCreate(params: Record) { - return this.runWithDiffDedupeClear(() => + return this.runWithGitReadCacheClear(() => refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) ) } 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..25a5a4e751b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1209,6 +1209,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/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/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index ff76246a24c..1cf6f7beafd 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1579,9 +1579,9 @@ function patchCachedWorkItemBody(cacheKey: string, body: string): void { } // Why: install once at module load — every dialog instance shares the cache, -// so a single subscription is enough. The preload bridge re-emits the -// main-process broadcast for every window, so each renderer invalidates its -// own cache when any window's mutation lands. We track the unsubscribe so +// so a single subscription is enough. Main targets the registered app renderer +// for non-origin mutations, and this listener invalidates the matching entry. +// We track the unsubscribe so // Vite HMR doesn't accumulate listeners across module reloads in dev. let workItemMutatedUnsub: (() => void) | undefined let workItemDetailsCacheEventUnsub: (() => void) | undefined @@ -6338,68 +6338,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 +7088,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 +7786,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 (
void) | undefined let workItemDetailsCacheEventUnsub: (() => void) | undefined @@ -6807,6 +6807,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 +7320,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 8d37800b8d9..c42be0d7bac 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -6865,7 +6865,9 @@ export default function TaskPage(): React.JSX.Element { labels: newIssueLabels, assignees: newIssueAssignees.map((assignee) => assignee.login) }, - { timeoutMs: 30_000 } + // Why: oversized-body recovery can require two 30-second writes + // after GitHub rejects the initial create request. + { timeoutMs: 65_000 } ) : await window.api.gh.createIssue({ repoPath: newIssueTargetRepo.path, @@ -6883,28 +6885,42 @@ export default function TaskPage(): React.JSX.Element { ) return } - toast.success( - translate('auto.components.TaskPage.3f9604efc7', 'Opened issue #{{value0}}', { - value0: result.number - }), - { - action: result.url - ? { - label: translate('auto.components.TaskPage.9c57663908', 'View'), - onClick: () => window.open(result.url, '_blank') - } - : undefined - } + const createdIssueToast = translate( + 'auto.components.TaskPage.3f9604efc7', + 'Opened issue #{{value0}}', + { value0: result.number } ) + const createdIssueToastOptions = { + action: result.url + ? { + label: translate('auto.components.TaskPage.9c57663908', 'View'), + onClick: () => window.open(result.url, '_blank') + } + : undefined + } + if (result.bodySaveWarning) { + toast.warning(createdIssueToast, { + ...createdIssueToastOptions, + description: result.bodySaveWarning + }) + } else { + toast.success(createdIssueToast, createdIssueToastOptions) + } setNewIssueOpen(false) - setNewIssueTitle('') - setNewIssueBody('') - setNewIssueLabels([]) - setNewIssueAssignees([]) - // Why: a successful submit is the only path that discards the recovery - // draft. Closing `newIssueOpen` in the same commit keeps the write-through - // effect (gated on it) from re-persisting the emptied fields. - clearNewIssueDraft() + if (result.bodySaveWarning) { + // Why: retain the unsaved body for recovery, but clear the title so + // reopening the composer cannot repeat the create with one click. + setNewIssueTitle('') + setNewIssueDraft({ title: '' }) + } else { + setNewIssueTitle('') + setNewIssueBody('') + setNewIssueLabels([]) + setNewIssueAssignees([]) + // Why: only a complete success discards the recovery draft; a partial + // body save keeps the original text available without another create. + clearNewIssueDraft() + } // Why: bump the nonce so the list refetches and shows the new issue. setTaskRefreshNonce((current) => current + 1) @@ -6973,7 +6989,8 @@ export default function TaskPage(): React.JSX.Element { newIssueTitle, openGitHubDetailPage, setDialogWorkItem, - clearNewIssueDraft + clearNewIssueDraft, + setNewIssueDraft ]) const handleCreateNewLinearProject = useCallback(async (): Promise => { diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 322f2170f4d..305e89ba35b 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1047,7 +1047,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(() => { diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index d80deb581e5..0f263bf5310 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -16,10 +16,18 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { formatCrashReportText, isReactErrorBoundaryReport, + type CrashReportDiagnosticBundle, type CrashReportRecord } from '../../../../shared/crash-reporting' import type { GitHubViewer } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { + CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID, + getCrashReportCopySubmissionFailure, + getCrashReportSubmitFailureNotice, + getCrashReportSubmitWarningNotice +} from './crash-report-submit-notice' +import { useCrashReportCopy } from './use-crash-report-copy' function formatSummary(report: CrashReportRecord): string { if (isReactErrorBoundaryReport(report)) { @@ -88,6 +96,7 @@ export function CrashReportDialogSurface({ () => (report ? formatCrashReportText(report, deferredNotes) : ''), [deferredNotes, report] ) + const copyCrashReportDetails = useCrashReportCopy(report, notes) const clearViewer = useCallback((): void => { viewerRequestIdRef.current += 1 @@ -121,17 +130,25 @@ export function CrashReportDialogSurface({ loadViewerForOpenDialog() }, [clearViewer, loadViewerForOpenDialog, open]) - const handleCopy = async (): Promise => { - const result = await window.api.crashReports.copyLatestDiagnostics( - report ? { reportId: report.id, notes } : { notes } - ) - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - translate('auto.components.crash.report.CrashReportDialog.8b8473c544', 'Crash report copied.') - ) + const showSubmitFailure = ( + error: unknown, + diagnosticBundle?: CrashReportDiagnosticBundle + ): void => { + const failure = { error, ...(diagnosticBundle ? { diagnosticBundle } : {}) } + const notice = getCrashReportSubmitFailureNotice(failure, includeDiagnosticLogs) + const copyFailure = getCrashReportCopySubmissionFailure(failure) + toast.error(notice.title, { + id: CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID, + description: notice.description, + duration: Infinity, + dismissible: true, + action: { + label: notice.actionLabel, + onClick: () => { + void copyCrashReportDetails(copyFailure) + } + } + }) } const dismissReportIfNeeded = async (): Promise => { @@ -164,22 +181,7 @@ export function CrashReportDialogSurface({ githubEmail: null }) if (!result.ok) { - if (result.diagnosticBundle?.status === 'uploaded') { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.b2e36f53a1', - 'Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.', - { value0: result.diagnosticBundle.ticketId } - ) - ) - } else { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.56a3dfa283', - 'Failed to send crash report.' - ) - ) - } + showSubmitFailure(result.error, result.diagnosticBundle) console.error('Failed to submit crash report:', result.error) return } @@ -188,17 +190,21 @@ export function CrashReportDialogSurface({ } onReportChange(result.report) setNotes('') - toast.success( - translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.') - ) + toast.dismiss(CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID) + const warningNotice = getCrashReportSubmitWarningNotice(result, includeDiagnosticLogs) + if (warningNotice) { + toast.warning(warningNotice.title, { description: warningNotice.description }) + } else { + toast.success( + translate( + 'auto.components.crash.report.CrashReportDialog.8e24fe4f75', + 'Crash report sent.' + ) + ) + } onOpenChange(false) } catch (error) { - toast.error( - translate( - 'auto.components.crash.report.CrashReportDialog.56a3dfa283', - 'Failed to send crash report.' - ) - ) + showSubmitFailure(error) console.error('Failed to submit crash report:', error) } finally { if (mountedRef.current) { @@ -304,7 +310,13 @@ export function CrashReportDialogSurface({
- diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts new file mode 100644 index 00000000000..b7b0c0dd34e --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + getCrashReportCopySubmissionFailure, + getCrashReportSubmitFailureNotice, + getCrashReportSubmitWarningNotice +} from './crash-report-submit-notice' + +describe('crash report submit notices', () => { + it('builds an allow-listed copy context with the exact sanitized failure reasons', () => { + expect( + getCrashReportCopySubmissionFailure({ + error: 'request failed at C:\\Users\\alice\\Orca', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'attachment timeout token=super-secret-value' + } + }) + ).toEqual({ + error: 'request failed at [redacted-path]', + diagnosticContext: { + status: 'not_uploaded', + reason: 'attachment timeout token=[redacted]' + } + }) + }) + + it('keeps the returned error and points attached-log failures to the no-log retry', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'status 413' }, true) + + expect(notice).toEqual({ + title: "Crash report wasn't sent", + description: + 'status 413. Uncheck "Attach recent diagnostic logs" and try again, or copy the details.', + actionLabel: 'Copy Details' + }) + }) + + it('uses connection guidance when diagnostic logs were already excluded', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'network unreachable' }, false) + + expect(notice.description).toBe( + 'network unreachable. Check your connection and try again, or copy the details.' + ) + expect(notice.description).not.toContain('Uncheck') + }) + + it('preserves an uploaded diagnostic ticket alongside the core failure', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'status 502', + diagnosticBundle: { + status: 'uploaded', + ticketId: 'ticket-123', + bundleSubmissionId: 'bundle-123', + bytes: 42, + spanCount: 2 + } + }, + true + ) + + expect(notice.description).toContain('status 502.') + expect(notice.description).toContain( + 'Diagnostic ticket ticket-123 was uploaded but not linked.' + ) + }) + + it('shows both failures when the attachment was already omitted', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'fallback host unreachable', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: request timed out after 60 seconds' + } + }, + true + ) + + expect(notice.description).toContain('fallback host unreachable.') + expect(notice.description).toContain( + 'Diagnostic logs were not attached: diagnostic log attachment failed: request timed out after 60 seconds.' + ) + expect(notice.description).toContain('Check your connection') + expect(notice.description).not.toContain('Uncheck') + }) + + it('safely surfaces Error messages and ignores unhelpful rejected values', () => { + expect( + getCrashReportSubmitFailureNotice({ error: new Error('IPC channel closed') }, false) + .description + ).toContain('IPC channel closed.') + expect( + getCrashReportSubmitFailureNotice({ error: { unexpected: true } }, false).description + ).toContain('The crash report request failed before it returned a reason.') + }) + + it('redacts paths and tokens before displaying transport errors', () => { + const token = `ghp_${'a'.repeat(30)}` + const notice = getCrashReportSubmitFailureNotice( + { + error: `request failed at C:\\Users\\alice\\Orca\\crash-reports.json token=${token}` + }, + false + ) + + expect(notice.description).not.toContain('alice') + expect(notice.description).not.toContain(token) + expect(notice.description).toContain('[redacted') + }) + + it('warns only when an opted-in diagnostic bundle was not attached', () => { + const result = { + ok: true as const, + report: null, + diagnosticBundle: { + status: 'not_uploaded' as const, + reason: 'bundle collection failed' + } + } + + expect(getCrashReportSubmitWarningNotice(result, true)).toEqual({ + title: 'Crash report sent without diagnostic logs', + description: 'Diagnostic logs were not attached: bundle collection failed' + }) + expect(getCrashReportSubmitWarningNotice(result, false)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts new file mode 100644 index 00000000000..29824b60416 --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts @@ -0,0 +1,134 @@ +import { translate } from '@/i18n/i18n' +import { + sanitizeCrashReportString, + type CrashReportCopySubmissionFailure, + type CrashReportDiagnosticBundle, + type CrashReportSubmitResult +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID = 'crash-report-submit-failure' + +export type CrashReportSubmitFailureNotice = { + title: string + description: string + actionLabel: string +} + +export type CrashReportSubmitWarningNotice = { + title: string + description: string +} + +type CrashReportSubmitFailureLike = { + error: unknown + diagnosticBundle?: CrashReportDiagnosticBundle +} + +function normalizedFailureMessage(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + const sanitized = sanitizeCrashReportString(message).trim() + return ( + sanitized || + translate( + 'auto.components.crash.report.submit.notice.unknownError', + 'The crash report request failed before it returned a reason.' + ) + ) +} + +function asSentence(message: string): string { + return /[.!?]$/.test(message) ? message : `${message}.` +} + +export function getCrashReportCopySubmissionFailure( + failure: CrashReportSubmitFailureLike +): CrashReportCopySubmissionFailure { + const diagnosticContext = + failure.diagnosticBundle?.status === 'uploaded' + ? { + status: 'uploaded' as const, + ticketId: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) + } + : failure.diagnosticBundle?.status === 'not_uploaded' + ? { + status: 'not_uploaded' as const, + reason: normalizedFailureMessage(failure.diagnosticBundle.reason) + } + : undefined + return { + error: normalizedFailureMessage(failure.error), + ...(diagnosticContext ? { diagnosticContext } : {}) + } +} + +export function getCrashReportSubmitFailureNotice( + failure: CrashReportSubmitFailureLike, + includeDiagnosticLogs: boolean +): CrashReportSubmitFailureNotice { + const ticketDetail = + failure.diagnosticBundle?.status === 'uploaded' + ? translate( + 'auto.components.crash.report.submit.notice.ticketUploaded', + 'Diagnostic ticket {{value0}} was uploaded but not linked.', + { value0: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) } + ) + : null + const omittedDetail = + failure.diagnosticBundle?.status === 'not_uploaded' + ? asSentence( + translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(failure.diagnosticBundle.reason) } + ) + ) + : null + const attachmentAlreadyOmitted = failure.diagnosticBundle?.status === 'not_uploaded' + const recovery = + includeDiagnosticLogs && !attachmentAlreadyOmitted + ? translate( + 'auto.components.crash.report.submit.notice.uncheckDiagnostics', + 'Uncheck "Attach recent diagnostic logs" and try again, or copy the details.' + ) + : translate( + 'auto.components.crash.report.submit.notice.checkConnection', + 'Check your connection and try again, or copy the details.' + ) + + return { + title: translate( + 'auto.components.crash.report.submit.notice.notSent', + "Crash report wasn't sent" + ), + description: [ + asSentence(normalizedFailureMessage(failure.error)), + ticketDetail, + omittedDetail, + recovery + ] + .filter((part): part is string => Boolean(part)) + .join(' '), + actionLabel: translate('auto.components.crash.report.submit.notice.copyDetails', 'Copy Details') + } +} + +export function getCrashReportSubmitWarningNotice( + result: Extract, + includeDiagnosticLogs: boolean +): CrashReportSubmitWarningNotice | null { + if (!includeDiagnosticLogs || result.diagnosticBundle?.status !== 'not_uploaded') { + return null + } + + return { + title: translate( + 'auto.components.crash.report.submit.notice.sentWithoutDiagnostics', + 'Crash report sent without diagnostic logs' + ), + description: translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(result.diagnosticBundle.reason) } + ) + } +} diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx new file mode 100644 index 00000000000..69b010907ac --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' +import { CRASH_REPORT_COPY_FAILURE_TOAST_ID, useCrashReportCopy } from './use-crash-report-copy' + +const copyLatestDiagnostics = vi.fn() + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + success: vi.fn() + } +})) + +function report(id = 'crash-1'): CrashReportRecord { + return { + id, + createdAt: '2026-05-16T01:00:00.000Z', + status: 'pending', + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: 'win32', + osRelease: 'test', + arch: 'x64', + electronVersion: '41', + chromeVersion: '141', + details: {} + } +} + +beforeEach(() => { + vi.clearAllMocks() + copyLatestDiagnostics.mockResolvedValue({ ok: true }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { crashReports: { copyLatestDiagnostics } } + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useCrashReportCopy', () => { + it('uses the latest edited notes from an older failure-toast action', async () => { + const failure: CrashReportCopySubmissionFailure = { + error: 'report request timed out', + diagnosticContext: { status: 'not_uploaded', reason: 'logs timed out' } + } + const { result, rerender } = renderHook(({ notes }) => useCrashReportCopy(report(), notes), { + initialProps: { notes: 'notes at submit' } + }) + const failureToastCopyAction = result.current + + rerender({ notes: 'notes edited while waiting' }) + await act(async () => failureToastCopyAction(failure)) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-1', + notes: 'notes edited while waiting', + submissionFailure: failure + }) + }) + + it("does not combine an older report action with a newer report's notes", async () => { + const { result, rerender } = renderHook( + ({ currentReport, notes }) => useCrashReportCopy(currentReport, notes), + { + initialProps: { + currentReport: report('crash-a'), + notes: 'latest notes for A' + } + } + ) + const reportACopyAction = result.current + + rerender({ currentReport: report('crash-b'), notes: 'private notes for B' }) + await act(async () => reportACopyAction()) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-a', + notes: 'latest notes for A' + }) + expect(JSON.stringify(copyLatestDiagnostics.mock.calls)).not.toContain('private notes for B') + }) + + it('keeps a returned copy failure visible with its safe reason', async () => { + copyLatestDiagnostics.mockResolvedValueOnce({ + ok: false, + error: 'Crash diagnostics are too large to copy safely.' + }) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + description: 'Crash diagnostics are too large to copy safely.', + duration: Infinity, + dismissible: true + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('shows a safe sticky error toast when copy IPC rejects', async () => { + copyLatestDiagnostics.mockRejectedValueOnce( + new Error('renderer destroyed with token=must-not-be-shown') + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + duration: Infinity, + dismissible: true + }) + expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('must-not-be-shown') + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.ts b/src/renderer/src/components/crash-report/use-crash-report-copy.ts new file mode 100644 index 00000000000..b2a604a072c --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.ts @@ -0,0 +1,69 @@ +import { useCallback, useRef } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_COPY_FAILURE_TOAST_ID = 'crash-report-copy-failure' + +function showCopyFailure(description?: string): void { + toast.error( + translate( + 'auto.components.crash.report.copy.copyFailed', + 'Crash report details could not be copied.' + ), + { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + ...(description ? { description } : {}), + duration: Infinity, + dismissible: true + } + ) +} + +export function useCrashReportCopy( + report: CrashReportRecord | null, + notes: string +): (submissionFailure?: CrashReportCopySubmissionFailure) => Promise { + const reportId = report?.id ?? null + const notesRef = useRef({ reportId, value: notes }) + // Why: a submission toast can outlive the render that created it while the + // user edits or changes reports; keep live notes scoped to that report. + if (notesRef.current.reportId === reportId) { + notesRef.current.value = notes + } else { + notesRef.current = { reportId, value: notes } + } + const reportNotes = notesRef.current + + return useCallback( + async (submissionFailure?: CrashReportCopySubmissionFailure): Promise => { + try { + const result = await window.api.crashReports.copyLatestDiagnostics({ + ...(report ? { reportId: report.id } : {}), + notes: reportNotes.value, + ...(submissionFailure ? { submissionFailure } : {}) + }) + if (!result.ok) { + showCopyFailure(result.error) + return + } + toast.dismiss(CRASH_REPORT_COPY_FAILURE_TOAST_ID) + toast.success( + translate( + 'auto.components.crash.report.CrashReportDialog.8b8473c544', + 'Crash report copied.' + ) + ) + } catch (error) { + console.error('Failed to copy crash report details:', error) + // Why: Sonner closes an action toast when clicked, so a sticky generic + // replacement keeps the failure actionable without exposing raw IPC detail. + showCopyFailure() + } + }, + [report, reportNotes] + ) +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 692fdb0279c..1a84a985ba5 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -50,6 +50,12 @@ function formatTimeAgo(ts: number, now: number): string { // drift away from the true transition moment. For past dones, stateHistory // entries already store the per-transition `startedAt` so we read it directly. function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { + // Why: idle subagent child rows are alive-but-idle (teammates persist + // between turns) — reading their synthetic entry as "done Xm ago" would + // mislabel a live teammate as finished. + if (agent.rowSource === 'subagent' && agent.state === 'idle') { + return null + } const entry = agent.entry if (entry.state === 'done') { return entry.stateStartedAt @@ -163,9 +169,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const handleActivate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - onActivate(agent.tab.id, agent.paneKey) + // Why: subagent child rows have no pane of their own; they focus the + // parent pane whose session spawned them. + onActivate(agent.tab.id, agent.activationPaneKey ?? agent.paneKey) }, - [onActivate, agent.tab.id, agent.paneKey] + [onActivate, agent.tab.id, agent.activationPaneKey, agent.paneKey] ) const handleSendTargetClickCapture = useCallback( (e: React.MouseEvent) => { @@ -332,8 +340,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ on the top row. The sub-rows (tool step, assistant response) are about the same agent and do not need the icon repeated next to them — keeping the icon only on the prompt row lets the sub-rows - indent under the prompt text cleanly. */} - {!hideIdentityIcon && ( + indent under the prompt text cleanly. Subagent child rows carry + the child's NAME in agentType (not an iconable agent — it would + render the unknown "?" glyph), and nesting under the parent + already conveys identity. */} + {!hideIdentityIcon && agent.rowSource !== 'subagent' && ( @@ -385,6 +396,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ relativeTimestamp={relativeTimestamp} expanded={expanded} hideExpand={hideExpand} + hideDismiss={agent.rowSource === 'subagent'} sendTargetStatus={sendTargetStatus} onDismiss={onDismiss} onToggleExpanded={handleToggleExpanded} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx index 13b3bafae6f..f194abbe508 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx @@ -8,6 +8,9 @@ type DashboardAgentRowTrailingControlsProps = { relativeTimestamp: string | null expanded: boolean hideExpand: boolean + /** Subagent child rows have no store entry of their own to dismiss — + * offering the X would be a silent no-op. */ + hideDismiss?: boolean sendTargetStatus?: 'eligible' | 'disabled' | 'sending' onDismiss: (paneKey: string) => void onToggleExpanded: () => void @@ -19,6 +22,7 @@ export function DashboardAgentRowTrailingControls({ relativeTimestamp, expanded, hideExpand, + hideDismiss = false, sendTargetStatus, onDismiss, onToggleExpanded, @@ -86,9 +90,17 @@ export function DashboardAgentRowTrailingControls({ {translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')} )} + {!sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( + + {relativeTimestamp} + + )} {/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X is visible by default, so the timestamp must yield there too. */} - {!sendTargetStatus && relativeTimestamp !== null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp !== null && ( )} - {!sendTargetStatus && relativeTimestamp === null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp === null && ( - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +368,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/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 7c6f043bd8a..8c8e7ec16a0 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -93,6 +93,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + // Why: hard no-op for read-only tabs (AI Vault View Log) even if a stray + // user/autosave save reached the queue — the integrity invariant is that + // View Log never writes the agent-owned artifact through editor paths. + if (liveFile.readOnly === true) { + return + } + // Why: explicit user saves proceed even while suspended (the banner // warned) and clear both suspension flags below. if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { diff --git a/src/renderer/src/components/editor/editor-autosave.test.ts b/src/renderer/src/components/editor/editor-autosave.test.ts index 20d32f8f6a7..77f6065153d 100644 --- a/src/renderer/src/components/editor/editor-autosave.test.ts +++ b/src/renderer/src/components/editor/editor-autosave.test.ts @@ -91,6 +91,10 @@ describe('canAutoSaveOpenFile', () => { ) ).toBe(false) }) + + it('rejects read-only edit tabs (AI Vault View Log)', () => { + expect(canAutoSaveOpenFile(makeOpenFile({ readOnly: true }))).toBe(false) + }) }) describe('normalizeAutoSaveDelayMs', () => { diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index c36f91e8a9b..d2a5034f5cb 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -70,6 +70,11 @@ export function isWorkingTreeCombinedDiffTab(file: OpenFile): boolean { } export function canAutoSaveOpenFile(file: OpenFile): boolean { + // Why: read-only tabs (AI Vault View Log) must never autosave — writing an + // agent-owned transcript can corrupt the provider's resume history. + if (file.readOnly === true) { + return false + } // Why: single-file editors and one-file unstaged diffs have an unambiguous // write target. Combined diff and conflict-review tabs can represent multiple // paths, so autosave must stay out of those surfaces until they have their diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 2d601b99d6d..16f5e557faa 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -23,8 +23,14 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil const renameCancelledRef = useRef(false) const renameFocusFrameRef = useRef(null) const currentFileName = basename(activeFile.filePath) + // Why: read-only tabs (AI Vault View Log) are never renameable — rename would + // rewrite the agent-owned artifact's backing path. const canRename = - activeFile.mode === 'edit' && !activeFile.diffSource && !activeFile.conflict && !isRenaming + activeFile.mode === 'edit' && + !activeFile.diffSource && + !activeFile.conflict && + !activeFile.readOnly && + !isRenaming const openRenameInput = (): void => { if (!canRename) { diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 636b910b77d..c44df176150 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -23,6 +23,7 @@ export type FileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string loadError?: string } diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts new file mode 100644 index 00000000000..d8b4747a9b5 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' + +function makeFile(id: string, overrides: Partial = {}): OpenFile { + return { + id, + filePath: `/repo/${id}.ts`, + relativePath: `${id}.ts`, + worktreeId: 'worktree-1', + language: 'typescript', + mode: 'edit', + isDirty: false, + ...overrides + } +} + +describe('createEditorPanelDraftSelector', () => { + it('limits draft invalidations to the panel that owns each keystroke', () => { + const files = Array.from({ length: 200 }, (_, index) => makeFile(`file-${index}`)) + let editorDrafts: Record = {} + let wholeMapInvalidations = 0 + let scopedInvalidations = 0 + let unrelatedPanelInvalidations = 0 + const selectors = files.map(createEditorPanelDraftSelector) + const previousSelections = selectors.map((selector) => selector({ editorDrafts })) + + for (let edit = 0; edit < 200; edit += 1) { + const previousDrafts = editorDrafts + editorDrafts = { ...editorDrafts, 'file-0': `edit-${edit}` } + for (let panelIndex = 0; panelIndex < files.length; panelIndex += 1) { + if (previousDrafts !== editorDrafts) { + wholeMapInvalidations += 1 + } + const nextSelection = selectors[panelIndex]({ editorDrafts }) + if (previousSelections[panelIndex] !== nextSelection) { + scopedInvalidations += 1 + if (panelIndex !== 0) { + unrelatedPanelInvalidations += 1 + } + previousSelections[panelIndex] = nextSelection + } + } + } + + expect(wholeMapInvalidations).toBe(40_000) + expect(scopedInvalidations).toBe(200) + expect(unrelatedPanelInvalidations).toBe(0) + }) + + it('includes preview and selected conflict-review drafts but excludes unrelated files', () => { + const preview = makeFile('preview', { markdownPreviewSourceFileId: 'source' }) + const conflictReview = makeFile('review', { + mode: 'conflict-review', + conflictReview: { selectedFileId: 'selected' } as NonNullable + }) + const editorDrafts = { + preview: 'preview draft', + source: '', + review: 'review draft', + selected: 'selected draft', + unrelated: 'other draft' + } + const selectPreviewDrafts = createEditorPanelDraftSelector(preview) + const selectConflictDrafts = createEditorPanelDraftSelector(conflictReview) + const selectNoDrafts = createEditorPanelDraftSelector(null) + + expect(selectPreviewDrafts({ editorDrafts })).toEqual({ + preview: 'preview draft', + source: '' + }) + expect(selectConflictDrafts({ editorDrafts })).toEqual({ + review: 'review draft', + selected: 'selected draft' + }) + expect(selectNoDrafts({ editorDrafts })).toEqual({}) + }) + + it('includes overview conflict drafts and ignores unrelated draft replacements', () => { + const conflictReview = makeFile('review', { + filePath: 'C:\\repo', + mode: 'conflict-review', + conflictReview: { + source: 'live-summary', + snapshotTimestamp: 1, + entries: [ + { path: 'src/a.ts', conflictKind: 'both_modified' }, + { path: 'src\\b.ts', conflictKind: 'both_modified' } + ] + } + }) + const selectDrafts = createEditorPanelDraftSelector(conflictReview) + const editorDrafts = { + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '', + unrelated: 'other draft' + } + + const selection = selectDrafts({ editorDrafts }) + expect(selection).toEqual({ + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '' + }) + + expect( + selectDrafts({ editorDrafts: { ...editorDrafts, unrelated: 'changed elsewhere' } }) + ).toBe(selection) + }) +}) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.ts new file mode 100644 index 00000000000..8cc7e07fb27 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.ts @@ -0,0 +1,63 @@ +import type { AppState } from '@/store' +import { joinPath } from '@/lib/path' +import type { OpenFile } from '@/store/slices/editor' + +type EditorDraftState = Pick +type EditorPanelDraftSelector = (state: EditorDraftState) => Record + +const EMPTY_EDITOR_PANEL_DRAFTS = Object.freeze({}) as Record + +export function createEditorPanelDraftSelector( + activeFile: OpenFile | null +): EditorPanelDraftSelector { + // Why: previews and conflict review can render a related file, but drafts + // from every other panel must not wake this editor on each keystroke. + const fileIds = activeFile + ? Array.from( + new Set( + [ + activeFile.id, + activeFile.markdownPreviewSourceFileId, + activeFile.conflictReview?.selectedFileId, + ...(activeFile.mode === 'conflict-review' && !activeFile.conflictReview?.selectedFileId + ? (activeFile.conflictReview?.entries ?? []).map((entry) => + joinPath(activeFile.filePath, entry.path) + ) + : []) + ].filter((fileId): fileId is string => Boolean(fileId)) + ) + ) + : [] + let previousDrafts: AppState['editorDrafts'] | null = null + let previousSelection = EMPTY_EDITOR_PANEL_DRAFTS + + return (state) => { + // Why: every Zustand write reruns the selector. The slice identity guard + // keeps unrelated terminal/status traffic allocation-free. + if (previousDrafts === state.editorDrafts) { + return previousSelection + } + previousDrafts = state.editorDrafts + + const changed = fileIds.some((fileId) => { + const draft = state.editorDrafts[fileId] + return ( + draft !== previousSelection[fileId] || + (draft === undefined && Object.prototype.hasOwnProperty.call(previousSelection, fileId)) + ) + }) + if (!changed) { + return previousSelection + } + + const nextSelection: Record = {} + for (const fileId of fileIds) { + const draft = state.editorDrafts[fileId] + if (draft !== undefined) { + nextSelection[fileId] = draft + } + } + previousSelection = nextSelection + return previousSelection + } +} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index adbb6eafb9d..fc4efff0a5c 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -109,6 +109,27 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => { }) }) +describe('getEditorPanelRenderModel read-only raw rendering (AI Vault View Log)', () => { + it('renders a read-only markdown log as raw source with no markdown viewer or chrome', () => { + const model = renderModel({ activeFile: markdownFile({ readOnly: true }) }) + + // Raw text renderer, not the rich/preview markdown surface. + expect(model.isMarkdown).toBe(false) + expect(model.hasViewModeToggle).toBe(false) + expect(model.canShowMarkdownPreview).toBe(false) + expect(model.canExportMarkdownToPdf).toBe(false) + expect(model.canShowMarkdownTableOfContents).toBe(false) + // Real language is preserved so Monaco still tokenizes the read-only source. + expect(model.resolvedLanguage).toBe('markdown') + }) + + it('keeps markdown chrome for ordinary writable markdown tabs', () => { + const model = renderModel({ activeFile: markdownFile() }) + expect(model.isMarkdown).toBe(true) + expect(model.hasViewModeToggle).toBe(true) + }) +}) + describe('getEditorPanelRenderModel markdown export affordance', () => { it('enables export for rendered markdown edit tabs', () => { expect(renderModel({}).canExportMarkdownToPdf).toBe(true) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 901c694fad7..4f46a0f1717 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -54,6 +54,14 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' ? detectLanguage(activeFile.relativePath) : detectLanguage(activeFile.filePath) + // Why: an AI Vault View Log tab must show the exact raw bytes read-only. A + // rich/preview/mermaid/csv/notebook renderer would depart from raw text (and + // can look editable), so neutralize specialized viewers + view-mode chrome + // for read-only tabs. `resolvedLanguage` still drives read-only Monaco + // tokenization (e.g. jsonl stays colorized); only viewer selection is forced + // to a plain source rendering here. + const rawReadOnly = activeFile.mode === 'edit' && activeFile.readOnly === true + const viewerLanguage = rawReadOnly ? 'plaintext' : resolvedLanguage const worktreeEntries = gitStatusEntries ?? [] const branchEntries = gitBranchEntries ?? [] const matchingWorktreeEntry = @@ -77,13 +85,13 @@ export function getEditorPanelRenderModel({ matchingBranchEntry ) const markdownViewModes = getMarkdownViewModes({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) const hasViewModeToggle = markdownViewModes.length > 0 const defaultMarkdownViewMode = getDefaultMarkdownViewMode({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) @@ -95,7 +103,7 @@ export function getEditorPanelRenderModel({ ? storedMarkdownViewMode : defaultMarkdownViewMode const editorToggleModes = getEditorToggleModes({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) @@ -115,7 +123,7 @@ export function getEditorPanelRenderModel({ ? (editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null) : null const shouldShowMarkdownExportAction = - resolvedLanguage === 'markdown' && + viewerLanguage === 'markdown' && (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') const inlineMarkdownRenderMode = activeFile.mode === 'edit' && inlineMarkdownContent !== null @@ -147,15 +155,15 @@ export function getEditorPanelRenderModel({ worktreeEntries, resolvedLanguage, openFileState, - isMarkdown: resolvedLanguage === 'markdown', - isMermaid: resolvedLanguage === 'mermaid', - isCsv: resolvedLanguage === 'csv' || resolvedLanguage === 'tsv', - isNotebook: resolvedLanguage === 'notebook', + isMarkdown: viewerLanguage === 'markdown', + isMermaid: viewerLanguage === 'mermaid', + isCsv: viewerLanguage === 'csv' || viewerLanguage === 'tsv', + isNotebook: viewerLanguage === 'notebook', // Why: the preview renders the on-disk file, so diff surfaces only get it // when the modified side still exists on disk (canOpen excludes deleted // files and commit diffs whose content may not match the working tree). canOpenPreviewToSide: - canPreviewLanguage(resolvedLanguage) && + canPreviewLanguage(viewerLanguage) && (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, hasViewModeToggle, @@ -166,10 +174,10 @@ export function getEditorPanelRenderModel({ shouldShowMarkdownExportAction, canExportMarkdownToPdf, canShowMarkdownTableOfContents: - resolvedLanguage === 'markdown' && + viewerLanguage === 'markdown' && (hasViewModeToggle || activeFile.mode === 'markdown-preview'), canShowMarkdownPreview: canOpenMarkdownPreview({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) 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