>1&1,d=o&1;h!==0||d!==0?T.setTransform(new DOMMatrix().translateSelf(-h,-d)):T.setTransform(new DOMMatrix),i5.fillStyle=T,i5.fillRect(t,r,s,a)}function Wt(i5,e,t,r,s,a,o,n,L){;i5.save(),i5.beginPath(),i5.rect(t,r,s,a),i5.clip(),i5.beginPath();let T;if(typeof e=="function"){let _=.15/a*s;T=e(.15,_)}else T=e;let h={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let d of T.split(" ")){let _=d[0];if(_==="Z"){;i5.closePath(),h.lastCommand=_;continue}let p=je[_];if(!p){n.error(`Could not find drawing instructions for "${_}"`);continue}let c=d.substring(1).split(",");!c[0]||!c[1]||(p(i5,Je(c,s,a,t,r,true,o,0,0,false),h),h.lastCommand=_)};L!==void 0?(i5.strokeStyle=i5.fillStyle,i5.lineWidth=o*L,i5.stroke()):i5.fill(),i5.closePath(),i5.restore()}function Vt(i5,e,t,r,s,a){i5.beginPath();for(let o of e.split(" ")){let n=o[0];if(n==="Z"){i5.closePath();continue}let L=o.substring(1).split(",");if(!L[0]||!L[1])continue;let T=t+parseFloat(L[0])*s,h=r+parseFloat(L[1])*a;n==="M"?i5.moveTo(T,h):n==="L"&&i5.lineTo(T,h)}i5.clip()}function $t(i5,e,t,r,s,a,o,n,L){let T=new Path2D;T.rect(t,r,s,a),i5.clip(T),i5.beginPath();let h=o/12;i5.lineWidth=n*h;let d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let _ of e.d.split(" ")){let p=_[0];if(p==="Z"){;i5.closePath(),d.lastCommand=p;continue}let c=je[p];if(!c){L.error(`Could not find drawing instructions for "${p}"`);continue}let u=_.substring(1).split(",");!u[0]||!u[1]||(c(i5,Je(u,s,a,t,r,false,n,(e.leftPadding??0)*(h/2),(e.rightPadding??0)*(h/2)),d),d.lastCommand=p)};e.type===1?(i5.strokeStyle=i5.fillStyle,i5.stroke()):i5.fill(),i5.closePath()}function Ye(i5,e,t=0){return Math.max(Math.min(i5,e),t)}var je={C:(i5,e,t)=>{;i5.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),t.lastControlX=e[2],t.lastControlY=e[3],t.currentX=e[4],t.currentY=e[5]},L:(i5,e,t)=>{;i5.lineTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},M:(i5,e,t)=>{;i5.moveTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},Q:(i5,e,t)=>{;i5.quadraticCurveTo(e[0],e[1],e[2],e[3]),t.lastControlX=e[0],t.lastControlY=e[1],t.currentX=e[2],t.currentY=e[3]},T:(i5,e,t)=>{let r,s;t.lastCommand==="Q"||t.lastCommand==="T"?(r=2*t.currentX-t.lastControlX,s=2*t.currentY-t.lastControlY):(r=t.currentX,s=t.currentY),i5.quadraticCurveTo(r,s,e[0],e[1]),t.lastControlX=r,t.lastControlY=s,t.currentX=e[0],t.currentY=e[1]}};function Je(i5,e,t,r,s,a,o,n=0,L=0,T=true){let h=i5.map(d=>parseFloat(d)||parseInt(d));if(h.length<2)throw new Error("Too few arguments for instruction");for(let d=0;da){;s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=a}this.clear()}},Ce=class extends X0{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Qe=class extends X0{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},et="requestIdleCallback"in globalThis?Qe:Ce;var s0=class i{constructor(){this.fg=0;this.bg=0;this.extended=new P0}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},P0=class i2{constructor(e=0,t=0){this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){;this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){;this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){;this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new i2(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var U=class{constructor(){this._listeners=[];this._disposed=false}get event(){return this._event?this._event:(this._event=(e,t,r)=>{if(this._disposed)return M(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let a=M(()=>{let o=this._listeners.indexOf(s);o!==-1&&this._listeners.splice(o,1)});return r&&(Array.isArray(r)?r.push(a):r.add(a)),a},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:r}=this._listeners[0];t.call(r,e);return}default:{let t=this._listeners.slice();for(let{fn:r,thisArgs:s}of t)r.call(s,e)}}}dispose(){this._disposed||(this._disposed=true,this._listeners.length=0)}},c0;(s=>{function i5(a,o){return a(n=>o.fire(n))}s.forward=i5;function e(a,o){return(n,L,T)=>a(h=>n.call(L,o(h)),void 0,T)}s.map=e;function t(...a){return(o,n,L)=>{let T=new R0;for(let h of a)T.add(h(d=>o.call(n,d)));return L&&(Array.isArray(L)?L.push(T):L.add(T)),T}}s.any=t;function r(a,o,n){return o(n),a(L=>o(L))}s.runAndSubscribe=r})(c0||={});var tt={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},B0=2;var w0,J=class i3{constructor(e,t,r,s){this._document=e;this._config=t;this._unicodeService=r;this._logService=s;this._didWarmUp=false;this._cacheMap=new D0;this._cacheMapCombined=new D0;this._pages=[];this._activePages=[];this._workBoundingBox={top:0,left:0,bottom:0,right:0};this._workAttributeData=new s0;this._textureSize=512;this._onAddTextureAtlasCanvas=new U;this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=new U;this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._requestClearModel=false;this._clearModelGeneration=0;this._createNewPage(),this._tmpCanvas=it(e,this._config.deviceCellWidth*4+B0*2,this._config.deviceCellHeight+B0*2),this._tmpCtx=g(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:true}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=true)}_doWarmUp(){let e=new et(this._logService);for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let r=this._drawToCache(t,0,0,0,false,void 0);this._cacheMap.set(t,0,0,0,r)}})}beginFrame(){let e=this._requestClearModel;return this._requestClearModel=false,e}clearTexture(){if(this._pages.some(e=>e._glyphs.length>0||e.currentRow.x!==0||e.currentRow.y!==0)){this._evictAllPages();this._createNewPage()}}_evictAllPages(){for(let e of this._pages)this._onRemoveTextureAtlasCanvas.fire(e.canvas),e.canvas.remove();this._pages.length=0,this._activePages.length=0,this._overflowSizePage=void 0,this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=false,this._requestClearModel=true,this._clearModelGeneration++}_createNewPage(){if(i3.maxAtlasPages&&this._pages.length>=i3.maxAtlasPages){let t=this._pages.filter(T=>T.canvas.width*2<=(i3.maxTextureSize||4096)).sort((T,h)=>h.canvas.width!==T.canvas.width?h.canvas.width-T.canvas.width:h.percentageUsed-T.percentageUsed),r=-1,s=0;for(let T=0;TT.canvas.width!==a[0].canvas.width))this._evictAllPages();else{let o=a.map(T=>T.glyphs[0].texturePage).sort((T,h)=>T-h),n=this.pages.length-a.length,L=this._mergePages(a,n);L.version=++u0.nextVersion;for(let T=o.length-1;T>=0;T--)this._deletePage(o[T]);this.pages.push(L),this._requestClearModel=true,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(L.canvas)}}let e=new u0(this._document,this._textureSize);return this._pages.push(e),this._activePages.push(e),this._onAddTextureAtlasCanvas.fire(e.canvas),e}_mergePages(e,t){let r=e[0].canvas.width*2,s=new u0(this._document,r,e);for(let[a,o]of e.entries()){let n=a*o.canvas.width%r,L=Math.floor(a/2)*o.canvas.height;s.ctx.drawImage(o.canvas,n,L);for(let h of o.glyphs)h.texturePage=t,h.sizeClipSpace.x=h.size.x/r,h.sizeClipSpace.y=h.size.y/r,h.texturePosition.x+=n,h.texturePosition.y+=L,h.texturePositionClipSpace.x=h.texturePosition.x/r,h.texturePositionClipSpace.y=h.texturePosition.y/r;this._onRemoveTextureAtlasCanvas.fire(o.canvas);let T=this._activePages.indexOf(o);T!==-1&&this._activePages.splice(T,1)}return s}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw new Error("No color found for idx "+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,r,s){if(this._config.allowTransparency)return K;let a;switch(e){case 16777216:case 33554432:a=this._getColorFromAnsiIndex(t);break;case 50331648:let o=s0.toColorRGB(t);a=Z.toColor(o[0],o[1],o[2]);break;case 0:default:r?a=I0.opaque(this._config.colors.foreground):a=this._config.colors.background;break}return this._config.allowTransparency||(a=I0.opaque(a)),a}_getForegroundColor(e,t,r,s,a,o,n,L,T,h){let d=this._getMinimumContrastColor(e,t,r,s,a,o,n,T,L,h);if(d)return d;let _;switch(a){case 16777216:case 33554432:;this._config.drawBoldTextInBrightColors&&T&&o<8&&(o+=8),_=this._getColorFromAnsiIndex(o);break;case 50331648:let p=s0.toColorRGB(o);_=Z.toColor(p[0],p[1],p[2]);break;case 0:default:n?_=this._config.colors.background:_=this._config.colors.foreground}return this._config.allowTransparency&&(_=I0.opaque(_)),L&&(_=I0.multiplyOpacity(_,Ke)),_}_resolveBackgroundRgba(e,t,r){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,r,s){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,r,s,a,o,n,L,T,h){if(this._config.minimumContrastRatio===1||h)return;let d=this._getContrastCache(T),_=d.getColor(e,s);if(_!==void 0)return _??void 0;let p=this._resolveBackgroundRgba(t,r,n),c=this._resolveForegroundRgba(a,o,n,L),u=d0.ensureContrastRatio(p,c,this._config.minimumContrastRatio/(T?2:1));if(!u){d.setColor(e,s,null);return}let C=Z.toColor(u>>24&255,u>>16&255,u>>8&255);return d.setColor(e,s,C),C}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(e,t,r,s,a,o){let n=typeof e=="number"?String.fromCharCode(e):e;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display="none",o.append(this._tmpCanvas));let L=Math.min(this._config.deviceCellWidth*Math.max(n.length,2)+B0*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width{orcaProbeWeight=String(orcaProbeWeight);let orcaActualWeightToken=this._tmpCtx.font.match(/(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/)?.[1]??"400",orcaActualWeight=orcaActualWeightToken==="normal"?"400":orcaActualWeightToken==="bold"?"700":orcaActualWeightToken;if(/^(?:[1-8]\d{2}|900)$/.test(orcaProbeWeight)&&orcaActualWeight!==orcaProbeWeight){this.fontProbeMismatchCount=(this.fontProbeMismatchCount||0)+1;this.fontProbeLastMismatch={desired:orcaProbeWeight,actual:this._tmpCtx.font};try{globalThis.__orcaAtlasFontProbe&&globalThis.__orcaAtlasFontProbe(this.fontProbeLastMismatch)}catch(orcaFontProbeError){}}})(Y),this._tmpCtx.textBaseline=K0;let A0=n.length===1&&W0(n.charCodeAt(0)),t0=n.length===1&&ke(n.charCodeAt(0)),L0=this._getForegroundColor(t,E,F,r,R,m,_,p,d,V0(n.charCodeAt(0)));this._tmpCtx.fillStyle=L0.css;let A=t0?0:B0*2,n0=false;if(this._config.customGlyphs!==false){let b=this._workAttributeData.getUnderlineVariantOffset();n0=qe(this._tmpCtx,n,A,A,this._config.deviceCellWidth,this._config.deviceCellHeight,this._config.deviceCharWidth,this._config.deviceCharHeight,this._config.fontSize,this._config.devicePixelRatio,this._logService,y.css,b)}let a0=!A0,$;if(typeof e=="number"?$=this._unicodeService.wcwidth(e):$=this._unicodeService.getStringCellWidth(e),u){this._tmpCtx.save();let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;if(this._tmpCtx.lineWidth=b,this._workAttributeData.isUnderlineColorDefault())this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle;else if(this._workAttributeData.isUnderlineColorRGB())a0=false,this._tmpCtx.strokeStyle=`rgb(${s0.toColorRGB(this._workAttributeData.getUnderlineColor()).join(",")})`;else{a0=false;let r0=this._workAttributeData.getUnderlineColor();this._config.drawBoldTextInBrightColors&&this._workAttributeData.isBold()&&r0<8&&(r0+=8),this._tmpCtx.strokeStyle=this._getColorFromAnsiIndex(r0).css};this._tmpCtx.fillStyle=this._tmpCtx.strokeStyle,this._tmpCtx.beginPath();let i0=A,G=Math.ceil(A+this._config.deviceCharHeight)-D-(a?b*2:0),he=G+b*2,f0=this._workAttributeData.getUnderlineVariantOffset(),C0=0,v0=0;for(let r0=0;r0<$;r0++){let y0=false;this._tmpCtx.save(),C0=G,v0=he;let j=i0+r0*this._config.deviceCellWidth,Q0=i0+(r0+1)*this._config.deviceCellWidth;switch(this._workAttributeData.extended.underlineStyle){case 2:;this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G),this._tmpCtx.moveTo(j,he),this._tmpCtx.lineTo(Q0,he);break;case 3:;C0=this._config.deviceCharHeight+1,v0=C0+3*this._config.devicePixelRatio;let ye=new Path2D;ye.rect(j,C0,this._config.deviceCellWidth,v0-C0),this._tmpCtx.clip(ye);let Qt=this._config.deviceCellWidth,mt=v0-C0,ft=Qt/6,Et=mt/3,bt=[[0,2,1,3,2.4,3,0,.6],[5.5,0,2.5,3,1.1,3,4.1,0],[4,0,6,2,6,.6,5.4,0]];for(let _e of bt){this._tmpCtx.beginPath();for(let S0=0;S0<_e.length;S0+=2){let xe=j+_e[S0]*ft,Ne=v0-_e[S0+1]*Et;S0===0?this._tmpCtx.moveTo(xe,Ne):this._tmpCtx.lineTo(xe,Ne)};this._tmpCtx.closePath(),this._tmpCtx.fill()}y0=true;break;case 4:let Z0=f0===0?0:f0>=b?b*2-f0:b-f0;!(f0>=b)===false||Z0===0?(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j+Z0,G),this._tmpCtx.lineTo(Q0,G)):(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(j+Z0,G),this._tmpCtx.moveTo(j+Z0+b,G),this._tmpCtx.lineTo(Q0,G)),f0=We(Q0-j,b,f0);break;case 5:let vt=.6,Ot=.3,de=Q0-j,Se=Math.floor(vt*de),Re=Math.floor(Ot*de),It=de-Se-Re;this._tmpCtx.setLineDash([Se,Re,It]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break;case 1:default:;this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break};y0||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!n0&&this._config.fontSize>=12&&!this._config.allowTransparency&&n!==" "){;this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";let r0=this._tmpCtx.measureText(n);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in r0&&r0.actualBoundingBoxDescent>0){this._tmpCtx.save();let y0=new Path2D;y0.rect(i0,C0-Math.ceil(b/2),this._config.deviceCellWidth*$,v0-C0+Math.ceil(b/2)),this._tmpCtx.clip(y0),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=y.css,this._tmpCtx.strokeText(n,A,A+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(Q){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+D),this._tmpCtx.stroke()}if(n0||this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight),n==="_"&&!this._config.allowTransparency){let b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0);if(b)for(let D=1;D<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=y.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight-D),b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0),!!b);D++);}if(C){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),D=this._tmpCtx.lineWidth%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.stroke()}this._tmpCtx.restore();let q=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),g0;if(this._config.allowTransparency?g0=Gt(q):g0=fe(q,y,L0,a0),g0)return tt;let I=this._findGlyphBoundingBox(q,this._workBoundingBox,L,t0,n0,A),O,v;for(;;){if(this._activePages.length===0){let b=this._createNewPage();O=b,v=b.currentRow,v.height=I.size.y;break};O=this._activePages[this._activePages.length-1],v=O.currentRow;for(let b of this._activePages)I.size.y<=b.currentRow.height&&(O=b,v=b.currentRow);for(let b=this._activePages.length-1;b>=0;b--)for(let D of this._activePages[b].fixedRows)D.height<=v.height&&I.size.y<=D.height&&(O=this._activePages[b],v=D);if(I.size.x>this._textureSize){;this._overflowSizePage||(i3.maxAtlasPages&&this._pages.length>=i3.maxAtlasPages&&this._evictAllPages(),this._overflowSizePage=new u0(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=true,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),O=this._overflowSizePage,v=this._overflowSizePage.currentRow,v.x+I.size.x>=O.canvas.width&&(v.x=0,v.y+=v.height,v.height=0);break}if(v.y+I.size.y>=O.canvas.height||v.height>I.size.y+2){let b=false;if(O.currentRow.y+O.currentRow.height+I.size.y>=O.canvas.height){let D;for(let i0 of this._activePages)if(i0.currentRow.y+i0.currentRow.height+I.size.y=i3.maxAtlasPages&&v.y+I.size.y<=O.canvas.height&&v.height>=I.size.y&&v.x+I.size.x<=O.canvas.width)b=true;else{let i0=this._createNewPage();O=i0,v=i0.currentRow,v.height=I.size.y,b=true}}b||(O.currentRow.height>0&&O.fixedRows.push(O.currentRow),v={x:0,y:O.currentRow.y+O.currentRow.height,height:I.size.y},O.fixedRows.push(v),O.currentRow={x:0,y:v.y+v.height,height:0})}if(v.x+I.size.x<=O.canvas.width)break;v===O.currentRow?(v.x=0,v.y+=v.height,v.height=0):O.fixedRows.splice(O.fixedRows.indexOf(v),1)}return I.texturePage=this._pages.indexOf(O),I.texturePosition.x=v.x,I.texturePosition.y=v.y,I.texturePositionClipSpace.x=v.x/O.canvas.width,I.texturePositionClipSpace.y=v.y/O.canvas.height,I.sizeClipSpace.x/=O.canvas.width,I.sizeClipSpace.y/=O.canvas.height,v.height=Math.max(v.height,I.size.y),v.x+=I.size.x,O.ctx.putImageData(q,I.texturePosition.x-this._workBoundingBox.left,I.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,I.size.x,I.size.y),O.addGlyph(I),O.version=++u0.nextVersion,I}_findGlyphBoundingBox(e,t,r,s,a,o){t.top=0;let n=s?this._config.deviceCellHeight:this._tmpCanvas.height,L=s?this._config.deviceCellWidth:r,T=false;for(let h=0;h=o;h--){for(let d=0;d=0;h--){for(let d=0;d>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=t.rgba>>>24,L=t.rgba>>>16&255,T=t.rgba>>>8&255,h=Math.floor((Math.abs(s-n)+Math.abs(a-L)+Math.abs(o-T))/12),d=true;for(let _=0;_0)return false;return true}function it(i5,e,t){let r=i5.createElement("canvas");return r.width=e,r.height=t,r}function rt(i5,e,t,r,s,a,o,n,L=true){let T={foreground:a.foreground,background:a.background,cursor:K,cursorAccent:K,selectionForeground:K,selectionBackgroundTransparent:K,selectionBackgroundOpaque:K,selectionInactiveBackgroundTransparent:K,selectionInactiveBackgroundOpaque:K,overviewRulerBorder:K,scrollbarSliderBackground:K,scrollbarSliderHoverBackground:K,scrollbarSliderActiveBackground:K,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:L,devicePixelRatio:o,deviceMaxTextureSize:n,letterSpacing:s.letterSpacing,lineHeight:s.lineHeight,deviceCellWidth:i5,deviceCellHeight:e,deviceCharWidth:t,deviceCharHeight:r,fontFamily:s.fontFamily,fontSize:s.fontSize,fontWeight:s.fontWeight,fontWeightBold:s.fontWeightBold,allowTransparency:s.allowTransparency,drawBoldTextInBrightColors:s.drawBoldTextInBrightColors,minimumContrastRatio:s.minimumContrastRatio,colors:T}}function Ee(i5,e){for(let t=0;t=0){if(Ee(u.config,h))return u.atlas;u.ownedBy.length===1?(u.atlas.dispose(),e0.splice(c,1)):u.ownedBy.splice(C,1);break}}for(let c=0;c{;this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(e=600){;this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0){this._restartInterval(t);return}};this.isCursorVisible=false,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{;this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(t);return};this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{;this._renderCallback(),this._animationFrame=void 0})},600)},e)}pause(){;this.isCursorVisible=true,this._isIdlePaused=false,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){;this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){;this._isIdlePaused=false,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){;this.isCursorVisible=true,this._isIdlePaused=true,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}};function ve(i5,e,t){let r=new e.ResizeObserver(s=>{let a=s.find(L=>L.target===i5);if(!a)return;if(!("devicePixelContentBoxSize"in a)){;r?.disconnect(),r=void 0;return}let o=a.devicePixelContentBoxSize[0].inlineSize,n=a.devicePixelContentBoxSize[0].blockSize;o>0&&n>0&&t(o,n)});try{r.observe(i5,{box:["device-pixel-content-box"]})}catch{;r.disconnect(),r=void 0}return M(()=>r?.disconnect())}function nt(i5){return i5>65535?(i5-=65536,String.fromCharCode((i5>>10)+55296)+String.fromCharCode(i5%1024+56320)):String.fromCharCode(i5)}var j0=class i4 extends s0{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new P0;this.combinedData=""}static fromCharData(t){let r=new i4;return r.setFromCharData(t),r}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?nt(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){;this.fg=t[0],this.bg=0;let r=false;if(t[1].length>2)r=true;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let a=t[1].charCodeAt(1);56320<=a&&a<=57343?this.content=(s-55296)*1024+a-56320+65536|t[2]<<22:r=true}else r=true}else this.content=t[1].charCodeAt(0)|t[2]<<22;r&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return false;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return false;let r=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(r&&s)&&(r!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return false}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}};var J0=class extends W{constructor(t,r,s){super();this._renderCallback=t;this._coreBrowserService=r;this._optionsService=s;this._intervalDuration=0;this._blinkOn=true;this._needsBlinkInViewport=false;this._isViewportVisible=true;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",a=>{this.setIntervalDuration(a)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(M(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let r=this._blinkOn;this._blinkOn=true,this._interval=this._coreBrowserService.window.setInterval(()=>{;this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),r||this._renderCallback();return};this._clearInterval(),this._blinkOn||(this._blinkOn=true,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var te=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function ie(i5,e,t,r){let s=g(i5.createProgram());if(i5.attachShader(s,g(at(i5,i5.VERTEX_SHADER,e,r))),i5.attachShader(s,g(at(i5,i5.FRAGMENT_SHADER,t,r))),i5.linkProgram(s),i5.getProgramParameter(s,i5.LINK_STATUS))return s;r.error(i5.getProgramInfoLog(s)),i5.deleteProgram(s)}function at(i5,e,t,r){let s=g(i5.createShader(e));if(i5.shaderSource(s,t),i5.compileShader(s),i5.getShaderParameter(s,i5.COMPILE_STATUS))return s;r.error(i5.getShaderInfoLog(s)),i5.deleteShader(s)}function ot(i5,e){let t=Math.min(i5.length*2,e),r=new Float32Array(t);for(let s=0;sn.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};
-//# sourceMappingURL=addon-webgl.mjs.map
+}`,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,false,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,false,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,false,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,false,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,false,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=false){}_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,false,false),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,true,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(true)}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(false),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:true})}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=false;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(true),this._requestRedrawViewport()}clear(){this._clearModel(true);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(false),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=true;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(true),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=false,a=0;for(;this._charAtlas&&a++<32&&this._glyphRenderer.value.beginFrame();)s=true,this._clearModel(true),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=false;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=true,a=new ge(a,T.translateToString(true,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()&&($=true),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===false&&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=true,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(false),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:true}))}_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(i5,e,t=0){return Math.max(Math.min(i5,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:false,depth:false,preserveDrawingBuffer:true};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??true,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};
-diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
-index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf534a19e7 100644
+diff -ruN a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
--- a/src/GlyphRenderer.ts
+++ b/src/GlyphRenderer.ts
-@@ -74,7 +74,11 @@ out vec4 outColor;
+@@ -74,7 +74,11 @@
void main() {
if (v_texpage == 0) {
outColor = texture(u_texture[0], v_texcoord);
@@ -88,19 +85,19 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ }
}`);
}
-
-@@ -100,6 +104,8 @@ export class GlyphRenderer extends Disposable {
+
+@@ -100,6 +104,8 @@
private readonly _attributesBuffer: WebGLBuffer;
-
+
private _atlas: ITextureAtlas | undefined;
+ private _lastSeenClearModelGeneration: number = 0;
+ private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0;
private readonly _vertices: IVertices = {
count: 0,
-@@ -214,7 +220,15 @@ export class GlyphRenderer extends Disposable {
+@@ -214,7 +220,15 @@
}
-
+
public beginFrame(): boolean {
- return this._atlas ? this._atlas.beginFrame() : true;
+ if (!this._atlas) {
@@ -113,9 +110,9 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ this._lastSeenClearModelGeneration = generation;
+ return true;
}
-
+
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
-@@ -363,7 +377,14 @@ export class GlyphRenderer extends Disposable {
+@@ -363,7 +377,14 @@
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
@@ -131,9 +128,9 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
}
-@@ -374,7 +395,12 @@ export class GlyphRenderer extends Disposable {
+@@ -374,7 +395,12 @@
}
-
+
public setAtlas(atlas: ITextureAtlas): void {
- this._atlas = atlas;
+ if (this._atlas !== atlas) {
@@ -144,22 +141,21 @@ index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..c19e24f7963cfcfbca1cea4bf7f808bf
+ }
this.invalidateAtlasTextures();
}
-
-diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
-index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b326729342a28d 100644
+
+diff -ruN a/src/TextureAtlas.ts b/src/TextureAtlas.ts
--- a/src/TextureAtlas.ts
+++ b/src/TextureAtlas.ts
-@@ -133,6 +133,7 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -133,6 +133,7 @@
}
-
+
private _requestClearModel = false;
+ public clearModelGeneration = 0;
public beginFrame(): boolean {
const result = this._requestClearModel;
this._requestClearModel = false;
-@@ -140,15 +141,38 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -140,15 +141,38 @@
}
-
+
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
@@ -196,9 +192,9 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ this._requestClearModel = true;
+ this.clearModelGeneration++;
}
-
+
private _createNewPage(): AtlasPage {
-@@ -156,7 +180,7 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -156,7 +180,7 @@
// microtask to ensure it does not interrupt textures that will be rendered in the current
// animation frame which would result in blank rendered areas. This is actually not that
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
@@ -207,7 +203,7 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
// Find the set of the largest 4 images, below the maximum size, with the highest
// percentages used
const pagesBySize = this._pages.filter(e => {
-@@ -184,31 +208,30 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -184,31 +208,30 @@
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
// effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
@@ -217,37 +213,37 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
- this._onAddTextureAtlasCanvas.fire(newPage.canvas);
- return newPage;
- }
--
-- const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
-- const mergedPageIndex = this.pages.length - mergingPages.length;
+ // No merge can reduce the page count; evict and rebuild instead of
+ // adding a page at an index the renderer has no texture slot for.
+ this._evictAllPages();
+ } else {
+ const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
+ const mergedPageIndex = this.pages.length - mergingPages.length;
-
-- // Merge into the new page
-- const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
-- mergedPage.version = ++AtlasPage.nextVersion;
+
+- const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
+- const mergedPageIndex = this.pages.length - mergingPages.length;
+ // Merge into the new page
+ const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
+ mergedPage.version = ++AtlasPage.nextVersion;
-
-- // Delete the pages, shifting glyph texture pages as needed
-- for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
-- this._deletePage(sortedMergingPagesIndexes[i]);
-- }
+
+- // Merge into the new page
+- const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
+- mergedPage.version = ++AtlasPage.nextVersion;
+ // Delete the pages, shifting glyph texture pages as needed
+ for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
+ this._deletePage(sortedMergingPagesIndexes[i]);
+ }
-
-- // Add the new merged page to the end
-- this.pages.push(mergedPage);
+
+- // Delete the pages, shifting glyph texture pages as needed
+- for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
+- this._deletePage(sortedMergingPagesIndexes[i]);
+- }
+ // Add the new merged page to the end
+ this.pages.push(mergedPage);
-
+
+- // Add the new merged page to the end
+- this.pages.push(mergedPage);
+-
- // Request the model to be cleared to refresh all texture pages.
- this._requestClearModel = true;
- this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
@@ -257,9 +253,36 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
+ }
}
-
+
// All new atlas pages are created small as they are highly dynamic
-@@ -815,11 +838,16 @@ export class TextureAtlas implements ITextureAtlas {
+@@ -516,6 +539,26 @@
+ const fontStyle = italic ? 'italic' : '';
+ this._tmpCtx.font =
+ `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
++ // Orca font probe: canvas silently keeps its previous font when an assignment
++ // fails to parse, which rasterizes glyphs at a stale weight (bold-collapse
++ // family). Normalize Canvas font serialization: Chromium omits 400 and emits
++ // the keyword bold for 700.
++ {
++ const desiredWeight = String(fontWeight);
++ const actualWeightToken = this._tmpCtx.font.match(
++ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
++ )?.[1] ?? '400';
++ const actualWeight = actualWeightToken === 'normal'
++ ? '400'
++ : actualWeightToken === 'bold'
++ ? '700'
++ : actualWeightToken;
++ if (/^(?:[1-8]\d{2}|900)$/.test(desiredWeight) && actualWeight !== desiredWeight) {
++ (this as any).fontProbeMismatchCount = ((this as any).fontProbeMismatchCount || 0) + 1;
++ (this as any).fontProbeLastMismatch = { desired: desiredWeight, actual: this._tmpCtx.font };
++ try { (globalThis as any).__orcaAtlasFontProbe?.((this as any).fontProbeLastMismatch); } catch { /* diagnostics only */ }
++ }
++ }
+ this._tmpCtx.textBaseline = TEXT_BASELINE;
+
+ const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
+@@ -815,11 +858,16 @@
// Create a new page for oversized glyphs as they come up
if (rasterizedGlyph.size.x > this._textureSize) {
if (!this._overflowSizePage) {
@@ -269,32 +292,30 @@ index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..04703e830ecc907f91c67c1524b32672
+ }
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
this.pages.push(this._overflowSizePage);
-
+
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
+ this.clearModelGeneration++;
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
}
activePage = this._overflowSizePage;
-diff --git a/src/Types.ts b/src/Types.ts
-index 294582e3721d4614f6b14198229e67dfa820baec..bc708d9b6d2cbeb206bd0b13325162c0765bf8fb 100644
+diff -ruN a/src/Types.ts b/src/Types.ts
--- a/src/Types.ts
+++ b/src/Types.ts
-@@ -59,6 +59,9 @@ export interface ICharAtlasConfig {
+@@ -59,6 +59,9 @@
export interface ITextureAtlas extends IDisposable {
readonly pages: { canvas: HTMLCanvasElement, version: number }[];
-
+
+ // Each renderer must observe invalidation independently because atlases are shared.
+ readonly clearModelGeneration: number;
+
onAddTextureAtlasCanvas: IEvent;
onRemoveTextureAtlasCanvas: IEvent;
-
-diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
-index a951efba5c75e82735cd39b6b22c4b5d5fad5928..7cc90292d352b134e7daf2bb7857da8f3f34cb9d 100644
+
+diff -ruN a/src/WebglRenderer.ts b/src/WebglRenderer.ts
--- a/src/WebglRenderer.ts
+++ b/src/WebglRenderer.ts
-@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
+@@ -386,7 +386,10 @@
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch
index 3daad4fde68..f8ddaae9104 100644
--- a/config/patches/node-pty@1.1.0.patch
+++ b/config/patches/node-pty@1.1.0.patch
@@ -2,8 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
-@@ -5,9 +5,6 @@
+@@ -1,13 +1,18 @@
+ {
+ 'target_defaults': {
+ 'dependencies': [
+- "` host resolves on the desktop resolver outside the tunnel, and a source census keeps any DoH host-resolver mode from widening that leak. Network-service restart and provider journeys remain uncovered.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4150/refactor-remote-browser-to-client-hosted-electron-webviews"
+ ],
+ "invariant": "A client-hosted partition is derived only in main from stable Orca-profile, browser-profile, authority-connection, and execution-host identities. Raw identities and individually linkable component hashes never enter its path-safe partition name. Durable binding metadata must match and precede Chromium partition data before reuse. One live partition never changes execution host or proxy endpoint. Fixed SOCKS5 setup starts immediately after Session creation, before policy installation can yield or a persisted worker is awakened; no partition enters the webview allowlist until browser policy is installed, inherited connections are closed, and resolveProxy returns exactly that one listener. Initial route-partition attachment is blank-only. Its exact WebContents is quarantined before applying non-proxied WebRTC denial and remains navigation- and popup-denied if policy application or cleanup fails. Distinct live partitions, retained logical page generations, durable bindings, and binding-file reads remain bounded. No UDP transport a route-partition page can reach — WebRTC, WebTransport, or forced QUIC — emits a datagram to the desktop, the Direct Sockets constructors stay absent from every guest so no page can kill its renderer, and the process never enables a DoH host-resolver mode.",
+ "oracle": "Derive two delimiter-adversarial identities and require distinct full-digest path-safe partitions with no raw IDs or component hashes. Persist one binding, reload it, and reject replacement, malformed or oversized state, Chromium data without matching metadata, and the 513th binding. Prepare one partition and require setProxy with <-loopback> to be invoked immediately after getSession and before policy setup, then closeAllConnections and exact SOCKS5 resolveProxy while isAllowedPartition remains false; only then may it become live. Under real Electron, require direct controls for HTTP, HTTPS, WebSocket, redirects, subresources, and downloads, then require the fixed SOCKS session to route every equivalent request plus an otherwise-unresolvable `.test` hostname with zero direct target connections. Across two Electron launches, require immediate setProxy to route a forced worker wake and post-verification fetch. Reject DIRECT, endpoint retargeting, and capacity overflow. Require quarantine before disable_non_proxied_udp and admission; under real Electron require the unprotected control to emit STUN and the protected guest to emit zero direct UDP packets. Under real Electron require a direct control to emit WebTransport and forced-QUIC datagrams and the SOCKS partition to emit none, require an explicitly enabled Direct Sockets control to expose the constructors and die on construction, and require the shipped disable-features list to leave them undefined with the renderer alive. Capture a route partition's netLog across a dns-prefetch load and require the prefetched host to appear on a local resolver task while an unreferenced control host appears nowhere; require no source file to set a non-'off' secureDnsMode.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-identity.test.ts src/main/browser/browser-route-partition-binding-store.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-startup.test.ts src/main/window/createMainWindow.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-persisted-worker-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-tcp-egress.electron.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts src/main/startup/secure-dns-census.test.ts"
+ ],
+ "testFiles": [
+ "src/main/browser/browser-route-identity.test.ts",
+ "src/main/browser/browser-route-partition-binding-store.test.ts",
+ "src/main/browser/browser-route-session-registry.test.ts",
+ "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts",
+ "src/main/browser/browser-route-tcp-egress.electron.test.ts",
+ "src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "src/main/browser/browser-route-h3-egress.electron.test.ts",
+ "src/main/browser/browser-route-dns-prefetch.electron.test.ts",
+ "src/main/startup/secure-dns-census.test.ts",
+ "src/main/browser/browser-route-webcontents-registry.test.ts",
+ "src/main/browser/browser-session-registry.test.ts",
+ "src/main/browser/browser-session-startup.test.ts",
+ "src/main/window/createMainWindow.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/browser/browser-route-identity.test.ts",
+ "assertions": [
+ "aggregate partition and durable binding fingerprints are stable and opaque",
+ "delimiter-containing identities remain structurally distinct",
+ "individual component equality and Windows-illegal path separators are absent",
+ "empty and unbounded identity components are rejected"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-partition-binding-store.test.ts",
+ "assertions": [
+ "opaque bindings survive restart",
+ "replacement and malformed metadata fail closed",
+ "oversized metadata and Chromium data without a binding fail closed",
+ "persisted binding count is bounded"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-session-registry.test.ts",
+ "assertions": [
+ "policy, proxy, connection closure, and verification finish before allowlisting",
+ "DIRECT and endpoint retargeting are rejected",
+ "non-loopback listeners and invalid page generations are rejected before persistence",
+ "missing browser profiles consume no durable binding capacity",
+ "partial setup cleanup and concurrent setup settle exactly once",
+ "partition and page-generation counts are bounded",
+ "stale page cleanup cannot retire its replacement"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts",
+ "assertions": [
+ "a forced persisted-worker wake before proxy setup reaches the direct desktop control endpoint",
+ "getAllRunning is empty immediately after persistent Session creation in either arm",
+ "invoking setProxy before the forced wake routes it through SOCKS with no direct target request",
+ "a later worker message fetch remains routed after exact proxy verification"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-tcp-egress.electron.test.ts",
+ "assertions": [
+ "the unprotected Electron control reaches HTTP, HTTPS, WebSocket, redirect, subresource, and download targets directly",
+ "the fixed SOCKS session routes every equivalent TCP surface with zero direct target connections",
+ "an otherwise-unresolvable `.test` hostname is carried to SOCKS rather than desktop DNS"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "assertions": [
+ "the unprotected SOCKS-partition control emits direct STUN packets",
+ "disable_non_proxied_udp leaves the exact guest with zero direct UDP packets"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-h3-egress.electron.test.ts",
+ "assertions": [
+ "the direct control reaches a WebTransport endpoint over UDP and the route partition sends zero datagrams",
+ "a fetch forced onto QUIC by --origin-to-force-quic-on emits UDP directly and zero through the route partition",
+ "an explicit --enable-features exposes the Direct Sockets constructors in the control arm",
+ "constructing a TCPSocket in the control arm kills the guest renderer",
+ "the shipped disable-features list leaves every Direct Sockets constructor undefined and the renderer alive"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-dns-prefetch.electron.test.ts",
+ "assertions": [
+ "a dns-prefetch hostname in a SOCKS route partition still opens a local host-resolver job",
+ "that job runs as a system or built-in DNS task, neither of which traverses the partition proxy",
+ "a hostname the page never references produces no host-resolver events"
+ ]
+ },
+ {
+ "file": "src/main/startup/secure-dns-census.test.ts",
+ "assertions": [
+ "no source file configures a host resolver with a non-'off' secureDnsMode",
+ "the census matcher flags a known DoH offender"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-webcontents-registry.test.ts",
+ "assertions": [
+ "route guests apply non-proxied WebRTC denial before admission",
+ "policy failure remains quarantined through delayed or failed guest close"
+ ]
+ },
+ {
+ "file": "src/main/window/createMainWindow.test.ts",
+ "assertions": [
+ "a live route partition may attach only the normalized blank document",
+ "an arbitrary URL cannot be the initial route-partition document"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-20",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts src/main/startup/secure-dns-census.test.ts",
+ "result": "passed",
+ "durationSeconds": 21,
+ "summary": "The direct control emitted 5 WebTransport and 6 forced-QUIC datagrams while the SOCKS route partition emitted zero of each. With Direct Sockets explicitly enabled the control exposed TCPSocket/UDPSocket/TCPServerSocket and constructing one killed the guest renderer; the shipped disable-features list left all three undefined and the renderer alive. The dns-prefetch tripwire recorded HOST_RESOLVER_SYSTEM_TASK for the prefetched host with zero events for an unreferenced control host, documenting the accepted local-resolution residual.",
+ "notes": "The dns-prefetch expectation intentionally asserts the leak. Flip it to an empty event list when the upstream Electron PrefetchDNS fix lands."
+ },
+ {
+ "date": "2026-08-17",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-tcp-egress.electron.test.ts",
+ "result": "passed",
+ "durationSeconds": 1,
+ "summary": "The real Electron direct control reached all six TCP request surfaces; the fixed SOCKS session carried HTTP, HTTPS, WebSocket, redirects, subresources, downloads, and remote DNS with zero direct target connections."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-persisted-worker-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "result": "passed",
+ "durationSeconds": 10,
+ "summary": "Three files passed 28 tests. The two-launch worker control reached desktop localhost before proxy setup; immediate setProxy invocation routed the same wake and a later worker fetch through SOCKS with path-attributed direct counts remaining zero. Both resolved and rejected proxy setup keep failed-policy retries joined through connection cleanup. The protected WebRTC guest emitted zero direct UDP packets."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts",
+ "result": "passed",
+ "durationSeconds": 8,
+ "summary": "Two files passed 24 assertions; the real Electron control emitted direct STUN, while the exact per-guest policy emitted zero desktop UDP packets and policy failure remained quarantined through delayed or failed close."
+ },
+ {
+ "date": "2026-08-14",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-identity.test.ts src/main/browser/browser-route-partition-binding-store.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-startup.test.ts src/main/window/createMainWindow.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.5,
+ "summary": "Six files passed 150 opaque identity, durable collision binding, bounded partition/page, proxy-before-allowlist, policy reuse, profile startup, and blank-only attach tests."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 2,
+ "scope": "deterministic identity, binding-store, session-policy, and window-boundary tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "The deterministic suite passes locally; CI and real Electron soak history have not started."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The TCP A/B control first reached all six target paths directly, while the fixed SOCKS arm routed the same HTTP, HTTPS, WebSocket, redirect, subresource, and download traffic plus remote DNS with zero direct target connections. The two-launch worker control reached desktop localhost before setProxy; immediate setProxy routed the same wake and later fetch through SOCKS. The WebRTC control emitted four direct STUN packets until disable_non_proxied_udp reduced the capture to zero. Non-WebRTC UDP and network-service restart remain explicit residuals."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Derivation performs one bounded full SHA-256 aggregate partition hash and one binding hash. The registry admits at most 64 live or pending partitions, 64 logical page generations per partition, and 512 durable bindings per Orca profile. Concurrent preparation shares one promise and one Electron proxy application per partition. Binding-file reads are capped at 128 KiB. Writes are bounded synchronous profile-local JSON, fsync the temporary file before Windows-retried rename, and fsync the containing directory where supported."
+ },
+ "promotionCriteria": [
+ "Use the production paired journey to prove proxy application precedes the first popup and speculative request.",
+ "Prove spontaneous persisted-worker background events cannot issue network requests between Session creation and immediate proxy invocation on macOS, Linux, and Windows; add a worker hold only if that evidence requires it.",
+ "Prove network-service restart cannot reset the partition to DIRECT or system proxy.",
+ "Activate a trusted exact-WebContents registration/grant flow before navigation, and fence reload, popup, restore, profile-switch, renderer-crash, and stale-cleanup paths.",
+ "Disable or capture every remaining UDP, QUIC, DoH, and non-SOCKS desktop egress path on macOS, Linux, and Windows."
+ ],
+ "knownGaps": [
+ "The production client-page executor calls preparePage before renderer mounting and navigation grant.",
+ "A forced persisted worker is routed once setProxy is invoked immediately after Session creation; spontaneous background-event wake timing beyond the two-launch oracle still needs cross-platform soak evidence.",
+ "The route partition permits only blank initial attachment; the production executor registers and grants only the exact main-observed WebContents.",
+ "Network-service recovery, speculative requests, and popup traffic remain incompletely captured.",
+ "QUIC, DoH, WebTransport, and non-WebRTC UDP/direct-network paths are not yet disabled or proven fail closed.",
+ "Partition deletion, download/transfer draining, idle route release, disk quotas, and browser-profile cloning are later lifecycle stages.",
+ "Binding writes serialize in Electron main, and packaged hosts rely on Orca's per-userData single-instance lock. Activation still needs an explicit guard for dev instances that share userData or a cross-process CAS/lock.",
+ "Sequential proxy or policy setup failures retain durable bindings and can exhaust the 512-binding ledger. Activation requires bounded tombstone recovery and partition garbage collection.",
+ "Each preparePage synchronously reads and parses bounded binding metadata on Electron main; activation requires latency evidence or a safely invalidated cache before this becomes frequent."
+ ],
+ "demotionRule": "Keep experimental or demote if raw identities enter a partition path, a durable binding mismatch is reused, a partition retargets to another execution host or live listener, a route partition becomes attachable before exact proxy verification, initial attachment can navigate beyond blank, stale cleanup retires a replacement, admission exceeds a declared cap, or any browser request reaches desktop DNS, TCP, UDP, localhost, or system proxy outside the selected route."
+ },
+ {
+ "id": "browser-client-host.webcontents-quarantine",
+ "title": "Route guests stay blank and popup-denied until exact registration",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "browser-runtime",
+ "layer": "electron-main-webcontents-authority",
+ "surfaces": [
+ "route guest did-attach lifecycle",
+ "exact host renderer and WebContents ownership",
+ "top-frame preload request admission",
+ "document-owned retained renderer webviews",
+ "logical page-generation registration",
+ "post-registration navigation grant",
+ "route popup fail-closed policy"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["remote-runtime", "ssh", "wsl"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": [],
+ "coverageNotes": "Deterministic main-process fakes cover route-Session recognition after logical release, prepared-page renderer ownership, stale and live waiters sharing pending partition setup, generic-policy-before-route-lockdown ordering, blank quarantine, exact guest/renderer/partition/logical-page registration, opaque page-authority replacement, a separate navigation grant, repeated navigation, invalid-scheme denial, dynamic page-authority revocation, popup denial, delayed/failed admission closure, exact page-release closure, retirement-start and destroyed-inspection failure, shared-partition retirement ordering, policy retention until every destroyed acknowledgement, guest process loss/destruction, opaque lifecycle claims across spontaneous destruction and WebContents ID reuse, owning-renderer process loss before, during, and after registration, mutable input isolation, page conflicts, and token-safe destroyed cleanup. The production page-command executor additionally retains an exact execution-host route, prepares its immutable partition, mounts one trusted renderer generation blank, registers and grants its exact guest, loads only normalized HTTP(S), and attempts every safe generation-scoped cleanup after failure, abort, or retirement while holding network resources without destruction proof. A production main-process bridge binds mount and retirement requests to the exact current renderer object and document generation, rejects sender spoofing and WebContents-ID reuse, carries no target URL, bounds pending requests, aborts, timeouts, malformed replies, and lifecycle replacement, and restores only an exact surviving renderer document after blocked or failed provisional navigation. The top-frame preload now queues and validates bounded requests before one replaceable subscriber, while a document-owned renderer registry mounts only blank route webviews, bounds global and per-partition guests, and fences attachment, retirement, destruction, and renderer loss by the full page tuple. Real Electron 43.1.0 proves exact main-frame reply admission, delayed guest-ID readiness, blank attachment, host ownership, destruction, and fail-closed denied attachment. Normal browser Sessions and paired web clients bypass the renderer registry. Production host composition and client placement activate the executor only after capability-gated placement preparation. Process/document swaps, worker/request/download settlement, traffic containment, and provider journeys remain uncovered.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4150/refactor-remote-browser-to-client-hosted-electron-webviews"
+ ],
+ "invariant": "A prepared route page binds its opaque authority to one exact main-observed renderer WebContents while that page generation is active or retiring. Before requesting a blank guest, the production command executor must retain the exact execution-host route, verify its key, and prepare the route/profile partition for one still-current renderer generation. Main sends the approved opaque partition and exact page generation, but no target URL, through one bounded request admitted against the exact current renderer object and document generation; only a schema-valid reply from that same object, request, operation, and page generation can settle it. Preload accepts requests only in the top frame, binds outcomes to the admitted request, fails overflow and unavailable subscribers within a bounded interval, and fences callbacks replaced by a newer document subscriber. Renderer retains each blank webview under one non-React document host keyed by the exact partition, page ID, and generation, never reparents it, and holds an attached or process-lost generation until exact destruction. Renderer replacement, WebContents-ID reuse, abort, timeout, malformed reply, disposal, and process loss fence pending work without admitting a late reply. Renderer-document replacement retires before commit, while blocked external navigation is ignored and failed or cancelled provisional navigation restores only the exact surviving frame. A WebContents whose Electron Session has belonged to a prepared route partition is quarantined synchronously after generic guest policy installation, including when page authority disappears between will-attach and did-attach. It must be a live blank webview owned by the exact renderer WebContents. Nonblank navigation remains denied until main matches the exact partition, browser page, page-host generation, guest WebContents, renderer WebContents, and opaque live page authority, then issues a separate grant. Every later navigation rechecks the same opaque guest incarnation and page authority, so reusing a logical tuple or WebContents ID cannot revive a stale guest. Releasing that authority, losing or destroying the guest process, or losing its owning renderer before, during, or after registration synchronously fences pending authority admission, ends exact logical authority, revokes navigation, and closes any attached guest through the same retirement barrier. Failed, aborted, or retired creation attempts revoke the exact guest and renderer mount before releasing the Session and route, attempt every safe cleanup, and never clear or admit a replacement generation after uncertain cleanup. Session and route release require exact guest destruction proof; an ambiguous mount outcome retains them. If close is delayed or fails, the partition stays outside attachment admission and route policies remain installed until the exact destroyed acknowledgement. Route popups remain denied throughout. Active and retiring page maps and pending renderer requests are bounded, stale callbacks cannot retire or clear replacements, and non-route browser guests retain existing behavior.",
+ "oracle": "Prepare a page for one renderer and require authority lookup and registration by a sibling renderer to fail; reject owner replacement while the generation is active or retiring, then lose the owning renderer before guest registration and require only its prepared pages to retire. Pause partition setup, lose the owner, and require its waiter to fail without authority; add a live sibling waiter to the same pending setup and require only that waiter to admit; exceed the configured waiter cap and require immediate refusal. Make policy cleanup throw in the first of two owned partitions and require the sweep to retire both without restoring admission. Through the production executor, retain one exact route, prepare its partition, mount blank on one current renderer, register and grant the exact guest in order, then load only its normalized HTTP(S) URL. Queue renderer requests before subscription, replace one subscriber, overflow both request and page budgets, race attachment with retirement, delay guest identity until DOM readiness, lose the guest renderer, and require exact bounded settlement without reparenting or cross-partition collision. Under real Electron, require the main bridge to accept only the actual main-frame reply, match the positive renderer WebContents ID to did-attach-webview, destroy it on retirement, and reject and remove a denied unprepared guest. Reject a mismatched route, stale renderer or page generation, and file URL without touching Chromium. Abort or fail registration after mount and require exact guest, renderer, Session, and route cleanup in that order; make renderer cleanup reject but require destruction before later release, and keep the generation fenced from replacement. Make guest retirement fail or mount outcome ambiguous and require every safe cleanup while Session and route remain retained. Destroy a guest spontaneously before command cleanup, reuse its WebContents identity, and require the old opaque claim not to retire the replacement while the replacement claim still can. Attach a blank route guest and require nonblank navigation plus every popup to be denied. Reject a wrong renderer, wrong page generation, nonblank initial document, wrong WebContents type, destroyed guest, and excess attachment; delay or fail its close and require navigation and popups to remain denied. Register the exact prepared tuple and prove navigation remains denied until a second grant; then allow repeated HTTP(S) navigation while continuing to deny file navigation and popups. Release logical page authority with delayed guest close; require immediate navigation revocation, one exact close, no partition admission, no policy cleanup, and same-tuple preparation refusal until destroyed acknowledgement. Make destroyed inspection throw and require retirement plus navigation to remain fail-closed until the explicit destroyed event. Acknowledge destruction, require one policy cleanup, reprepare the tuple, replay stale acknowledgement, and prove the replacement remains admitted. Make retirement startup throw and require policy plus capacity to remain fail-closed. Retire two pages sharing one partition and require cleanup only after both exact acknowledgements. Emit guest render-process-gone and destroyed, and emit owning-renderer process loss through the main-window boundary; require exact logical retirement, close, and grant revocation while unrelated renderer guests and prepared pages survive. Reject retirement for the wrong opaque authority. Reprepare after ordinary release, require a new opaque authority, deny the stale guest, and admit an exact replacement. Destroy a registered guest under real destroyed state, verify best-effort listener release, replay stale cleanup, and prove its replacement remains grantable. Mutate caller input after registration and prove main-owned authority is unchanged. Attach a normal browser Session and require no route-registry mutation.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-renderer-bridge.test.ts src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts src/main/browser/browser-client-page-renderer-runtime.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-webcontents-registry.test.ts src/preload/browser-client-page-renderer-requests.test.ts src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts src/main/window/createMainWindow.test.ts src/main/window/renderer-document-navigation.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-renderer-bridge.test.ts src/main/browser/browser-client-page-renderer-runtime.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/window/createMainWindow.test.ts src/main/window/renderer-document-navigation.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-reconciliation-adapters.test.ts src/main/browser/browser-route-prepared-page-rekey.test.ts"
+ ],
+ "testFiles": [
+ "src/main/browser/browser-client-page-command-executor.test.ts",
+ "src/main/browser/browser-client-page-command-integration.test.ts",
+ "src/main/browser/browser-client-page-reconciliation-adapters.test.ts",
+ "src/main/browser/browser-client-page-renderer-bridge.test.ts",
+ "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts",
+ "src/main/browser/browser-client-page-renderer-runtime.test.ts",
+ "src/main/browser/browser-route-session-registry.test.ts",
+ "src/main/browser/browser-route-prepared-page-rekey.test.ts",
+ "src/main/browser/browser-route-webcontents-registry.test.ts",
+ "src/preload/browser-client-page-renderer-requests.test.ts",
+ "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts",
+ "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts",
+ "src/main/window/createMainWindow.test.ts",
+ "src/main/window/renderer-document-navigation.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/browser/browser-client-page-command-executor.test.ts",
+ "assertions": [
+ "exact execution route and trusted renderer precede blank guest registration and grant",
+ "navigation targets only the retained opaque guest incarnation and exact generation and rejects local files",
+ "abort, failed registration, ambiguous mount, and retirement attempt every safe exact cleanup without releasing network resources before destruction proof or admitting a replacement after failure",
+ "close racing creation cannot retain a late page, cleanly absent creates remain forgettable, and unresolved cleanup stays generation-fenced",
+ "navigation fencing revokes retained exact claims once and rejects late in-flight creation before guest registration or grant"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-client-page-command-integration.test.ts",
+ "assertions": [
+ "a fresh new-authority dispatcher reclaims an old-authority executor page while real route Session and WebContents registries preserve exact cleanup"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-client-page-reconciliation-adapters.test.ts",
+ "assertions": [
+ "exact reclaim preserves the guest while rekeying renderer, Session, lifecycle claim, and navigation authority",
+ "rekey uncertainty retires both possible renderer identities and failed restore navigation destroys the affected guest before releasing retained resources"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-client-page-renderer-bridge.test.ts",
+ "assertions": [
+ "mount requests carry only the main-approved partition and exact page generation to the exact current main-frame document",
+ "sender spoofing, subframe replies, stale document frames, malformed replies, renderer replacement, WebContents-ID reuse, abort, and late replies fail closed",
+ "timeout, send failure, and request-ID collision release only their exact admission",
+ "pending mount and retirement requests share one bounded admission ledger and release it exactly once"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-client-page-renderer-runtime.test.ts",
+ "assertions": [
+ "one app-lifetime reply listener binds the exact current Electron renderer to the bridge",
+ "mount settlement and renderer retirement use the production IPC channel wiring"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts",
+ "assertions": [
+ "real Electron binds replies to the current main frame and matches the renderer guest ID to did-attach-webview",
+ "a document-owned blank webview survives attachment and is destroyed without reparenting on retirement",
+ "an unprepared partition is denied and its renderer host settles and disappears"
+ ]
+ },
+ {
+ "file": "src/preload/browser-client-page-renderer-requests.test.ts",
+ "assertions": [
+ "top-frame-only requests queue before subscription and fail overflow or missing subscribers within bounded limits",
+ "latest-subscriber replacement fences prior callbacks and outcomes cannot spoof request identity or operation"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts",
+ "assertions": [
+ "full partition, page, and generation tuples retain one exact blank guest with global and per-partition budgets",
+ "attachment, DOM readiness, retirement, destruction, process loss, and denied-attachment timeout settle exact state",
+ "cross-partition page IDs coexist while an attached retiring generation remains fenced until destruction"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-session-registry.test.ts",
+ "assertions": [
+ "a prepared Electron Session remains recognizable for late-attachment quarantine",
+ "prepared authority is bound to one renderer and owner loss retires unregistered pages",
+ "page release removes logical authority and tuple reuse receives a new opaque token",
+ "route policy stays installed and tuple reuse stays blocked until delayed exact-guest destruction settles"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-prepared-page-rekey.test.ts",
+ "assertions": [
+ "prepared authority moves only across an exact same-page same-renderer generation change",
+ "rekey preserves the opaque authority and leaves stale or conflicting input unchanged"
+ ]
+ },
+ {
+ "file": "src/main/browser/browser-route-webcontents-registry.test.ts",
+ "assertions": [
+ "route guests remain blank and popup-denied before exact registration and grant",
+ "guest, renderer, partition, and logical page generation must all match",
+ "later navigation rechecks live page authority and invalid schemes remain denied",
+ "only the exact opaque guest lifecycle claim can revoke navigation without destroying the guest",
+ "late, excess, and close-failed attachments remain navigation- and popup-denied",
+ "opaque authority replacement, page conflicts, mutable inputs, and stale cleanup fail closed",
+ "exact page retirement revokes and closes only the authority-matched guest",
+ "guest process loss and owning-renderer retirement end the exact authority while unrelated guests survive",
+ "normal browser Sessions remain outside the route registry"
+ ]
+ },
+ {
+ "file": "src/main/window/createMainWindow.test.ts",
+ "assertions": [
+ "generic guest policy installs before route lockdown can override popup behavior",
+ "every attached webview is offered to the route registry without changing normal guests",
+ "main-renderer process loss retires every route guest owned by that renderer",
+ "process loss and renderer-document replacement retire the exact client-page renderer generation, while blocked external, failed provisional, and same-document navigation preserve or restore only the surviving document"
+ ]
+ },
+ {
+ "file": "src/main/window/renderer-document-navigation.test.ts",
+ "assertions": [
+ "only packaged or same-origin development renderer-document replacement starts a lifecycle fence",
+ "failed, cancelled, and blocked provisional navigation restore the surviving document without clearing a committed fence",
+ "a delayed failure from a committed attempt cannot cancel a live same-URL replacement while its main frame is still loading",
+ "concurrent failure plus blocked replacement converges when the unchanged document becomes idle"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-15",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-renderer-bridge.test.ts src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts src/main/browser/browser-client-page-renderer-runtime.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-webcontents-registry.test.ts src/preload/browser-client-page-renderer-requests.test.ts src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts src/main/window/createMainWindow.test.ts src/main/window/renderer-document-navigation.test.ts",
+ "result": "passed",
+ "durationSeconds": 1.22,
+ "summary": "On the candidate atop #14753, twelve files passed 132 exact opaque-claim navigation revocation, in-flight create fencing, revocation-error cleanup, retained guest destruction separation, route Session, renderer bridge/registry, Electron lifecycle, and main-window document assertions."
+ },
+ {
+ "date": "2026-08-14",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-renderer-bridge.test.ts src/main/browser/browser-client-page-renderer-runtime.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/window/createMainWindow.test.ts src/main/window/renderer-document-navigation.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.8,
+ "summary": "Eight files and 174 tests passed deterministic route retention, trusted-renderer create ordering, exact renderer WebContents and main-frame-document fencing, subframe and stale-frame rejection, wrong-page and wrong-operation rejection, timeout/send/collision settlement, renderer-document replacement retirement, blocked external and same-document preservation, failed provisional restoration, stale same-URL failure rejection during a live replacement, concurrent failure plus blocked replacement convergence, production IPC wiring, composed real Session/WebContents registry lifecycle, abort and cleanup fencing, Session-index and prepared-owner fencing, bounded pending waiters, late/rejected route-WebContents quarantine, opaque-authority and process-loss fencing, fail-closed shared-partition release barriers, exact registration and navigation, popup denial, and main-window ordering tests."
+ },
+ {
+ "date": "2026-08-14",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-renderer-bridge.test.ts src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts src/main/browser/browser-client-page-renderer-runtime.test.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-webcontents-registry.test.ts src/preload/browser-client-page-renderer-requests.test.ts src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts src/main/window/createMainWindow.test.ts src/main/window/renderer-document-navigation.test.ts",
+ "result": "passed",
+ "durationSeconds": 2.7,
+ "summary": "Twelve files and 199 tests passed the existing main authority gate plus top-frame preload queueing, bounded outcomes, exact retained renderer tuples, delayed guest-ID readiness, idempotent retirement, resource budgets, process-loss fencing, and real Electron main-frame reply, blank attachment, denial, and destruction lifecycle."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 15,
+ "scope": "deterministic route Session and WebContents authority plus one isolated real-Electron renderer lifecycle"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "The deterministic and isolated real-Electron suite passes locally; CI and cross-platform soak history have not started."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The route-WebContents oracle first failed because no registry existed, and the Session oracle failed because no exact Session/page index existed. A first candidate blocked every navigation after the initial document committed because it incorrectly required the current URL to remain blank; repeated-navigation evidence failed until blank validation was limited to registration and grant. Mutable-input evidence then proved caller-owned data could alter stored authority until the registry copied it. Fresh review found late and rejected attachments could keep generic navigation policy, destroyed cleanup skipped listener release, and tuple reuse revived stale grants; the added deterministic cases fail that candidate and pass with retained Session recognition, deny-first admission, best-effort destroyed cleanup, and opaque authority tokens. The page-release oracle then failed because no exact retirement handshake existed; it passes with synchronous revocation plus policy cleanup held behind the exact destroyed callback. Guest-process, guest-destruction, and owning-renderer loss tests then failed because none ended logical page authority; the candidate routes all three through exact fenced retirement. Prepared-owner tests subsequently failed because a sibling renderer received the same live authority and no pre-registration renderer-retirement method existed; the candidate binds live authority to the exact owner and sweeps only that renderer's prepared pages. Fresh review then reproduced cleanup-failure sweep abort, pending authority resurrection, and live-partition success after owner loss; snapshot-first retirement, best-effort cleanup, renderer epochs, and the final async-boundary check now fence stale work while allowing an unrelated live waiter to admit the verified partition. The executor baseline lacked the module, exact guest navigation, and unregistered retirement. Later deterministic probes caught early route release after renderer failure, ambiguous mount leakage, failed-create readmission, discarded destruction proof, proof-ledger eviction, WebContents ID reuse, and stale navigation; the candidate keeps a bounded failed-page fence and retains an immutable opaque guest claim whose destruction promise, retirement, and navigation survive spontaneous loss without targeting a replacement. Renderer-lifecycle review then reproduced blocked-navigation detachment, stale same-URL cancellation, and a concurrent failure plus blocked-replacement stuck fence; constant document-generation and idle-state tracking makes those exact sequences green without retaining a navigation list. The renderer baseline had no preload consumer or retained page registry. Its first real-Electron candidate failed because getWebContentsId was transiently unavailable at did-attach, then because pre-attach destruction released state without rejecting the mount; DOM-readiness retry and exact pre-attach destruction settlement make both sequences green. A later deterministic sequence proved did-attach cancelled the timeout before a guest ID existed and retirement released the observed guest without destruction; retaining the deadline through valid identity and recording observed attachment make both sequences green. Fresh review then caught cached re-advertisement after guest identity became unreadable and an unhandled rejection when reply transport threw; exact live-ID comparison now fences the stale incarnation, while safe reply construction and transport containment leave main's timeout as the bounded recovery. Real Electron mutation/revert, lifetime page-generation authority, worker/request settlement, and traffic evidence remain required before activation."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Session, guest, page, and exact-owner lookups are O(1), install no polling, and retain at most 256 attached route guests in registry maps in addition to the lower 64-partition and 64-active-or-retiring-page-per-partition bounds. The production executor retains at most 256 active, creating, or failed page IDs; each observed guest owns one immutable lifecycle claim and one destruction promise, with no global tombstone ledger. The renderer bridge retains at most 512 pending mount or retirement requests, one timer and optional abort listener per request, and one app-lifetime IPC reply listener; settlement, abort, timeout, replacement, and disposal remove each exactly once. Preload independently retains at most 512 validated requests and one timer each, removes queued identifiers on every settlement, and exposes one replaceable subscriber. The renderer retains at most 256 exact page tuples and 64 per partition, one host, webview, timer, promise, and four lifecycle listeners each; attached retirement keeps bounded fail-closed tombstones until destruction. Renderer-document tracking retains only constant generation, active-fence, and cancellation state across concurrent navigation events. At most 64 callers per partition can await one setup promise. Renderer loss scans at most 256 guests plus 4,096 bounded active pages and snapshots only exact owner matches. Each guest retains at most one retirement callback. Historical Session recognition uses weak keys. Normal guests perform one bounded Session lookup at did-attach. Destroyed guests synchronously release exact mappings and attempt each listener removal independently."
+ },
+ "promotionCriteria": [
+ "Use Playwright CDP in real Electron to prove did-attach lockdown precedes page script, popup, redirect, worker, and speculative activity.",
+ "Extend exact WebContents release fencing to service workers, downloads, active requests, network-service recovery, ordinary document/process swaps, profile switch, lease replacement, and app shutdown.",
+ "Implement bounded generation-scoped popup reservations without any shell.openExternal or native-window fallback."
+ ],
+ "knownGaps": [
+ "The production host lifecycle, route resolver, and retained renderer surface are active for eligible paired Electron clients; headed and headless paired-runtime product journeys cover the normal creation path on macOS.",
+ "Real Electron main-frame reply, delayed guest-ID readiness, denial, destruction, and retained visible attachment are covered locally; process swaps, traffic isolation, and physical-platform behavior remain unvalidated.",
+ "Guest and owning-renderer process-loss fencing is deterministic only; real Electron crash/reload ordering and WebContents process recovery remain unvalidated.",
+ "Logical page release now revokes and closes the exact WebContents while holding policy cleanup for destroyed acknowledgement, but does not yet prove service-worker, download, active-request, connection, or persistent-storage settlement.",
+ "A close that never reaches destroyed or a retirement that cannot start intentionally keeps the route policy and capacity reserved; bounded force-close recovery must not clear policy under a live or unknown guest.",
+ "A failed or ambiguous create remains fenced until authenticated inventory reconciliation proves whether the guest exists; a real transport-loss recovery journey remains to be recorded.",
+ "The runtime-owned placement registry supplies monotonic page generations after retirement; persistence across a full authority restart remains deterministic rather than a packaged-upgrade journey.",
+ "Popup reservations do not exist; every route guest popup is denied.",
+ "The production caller derives renderer, page, host generation, and route identity from live main-owned state; physical Windows and Linux Electron ordering remain unvalidated."
+ ],
+ "demotionRule": "Demote if a route guest can navigate nonblank before exact registration and grant, a mismatched, released, crashed, or ownerless page remains authoritative, tuple reuse revives stale authority, page release clears route policy before exact guest destruction, a stale retirement clears a replacement, a popup reaches shell.openExternal or an unregistered native window, a rejected attachment retains navigation or popup capability, stale cleanup retires a replacement, normal browser guests change behavior, or any claimed exact identity is sourced only from renderer input."
+ },
+ {
+ "id": "browser-client-host.activated-placement",
+ "title": "Eligible paired Electron clients host new browser pages without dual execution",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "browser-runtime",
+ "layer": "paired-runtime-product-placement-and-materialization",
+ "surfaces": [
+ "browser tab creation from the renderer store",
+ "paired Electron host preparation",
+ "runtime-owned browser placement publication",
+ "retained client webview materialization",
+ "client-hosted automation routing",
+ "new-page-only kill switch"
+ ],
+ "platforms": ["macos", "linux", "windows", "ios"],
+ "providers": ["remote-runtime", "ssh", "wsl"],
+ "coveredPlatforms": ["macos", "ios"],
+ "coveredProviders": ["remote-runtime", "ssh", "wsl"],
+ "coverageNotes": "Fresh-build Playwright journeys run the same production store action against an isolated headed Electron server and a real headless orca serve host. They prove one immutable client placement owns one real retained guest on the viewing desktop, the server owns no duplicate guest, no screencast frame renders, browser.snapshot reaches the client guest, disabling the setting preserves that guest, and the next page uses the legacy server engine. Deterministic contracts cover omitted placement, missing capabilities, explicit server placement, exact renderer-store materialization after delayed publication, no fallback after client-create failure, folder workspaces, git worktrees, browserless hosts, native and WSL routes, exact connected SSH authority, reconnect command replay, lease replacement, imported-inventory cleanup, bounded retirement, and shared remote screencast fanout for multiple independent viewers. A published v1.4.184 package runs both skew directions: an old client omits placement against the current host, while a current client capability-downgrades against the old host; each creates one server guest, no client guest, and returns the exact snapshot marker. A current iOS Simulator client paired to that legacy packaged host visibly loads Example Domain through the preserved server-hosted surface. A Docker OpenSSH target proves container-only DNS and localhost through both ssh2 and system-SSH routes. Physical Windows/Linux Electron and physical mobile journeys remain gaps.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4150/refactor-remote-browser-to-client-hosted-electron-webviews"
+ ],
+ "invariant": "Placement is selected exactly once before page creation. An eligible, capable paired Electron desktop creates one client-hosted guest; old, ineligible, web/mobile, browserless-client, or explicit-server callers use the unchanged server path. Omitted placement remains server-hosted. Once client placement is selected, failure is surfaced and must not create, migrate, or fall back to a server guest. The acknowledged exact environment, worktree or folder workspace, remote page, and target group must materialize before the renderer reports success. Client pages bypass screencast and remote-input rendering, while agent and CLI automation routes only to the exact client guest. Disabling the kill switch affects only later pages. Reconnect, replacement, and cleanup preserve exact authority and never revive or retire another generation.",
+ "oracle": "Create through the normal renderer-store action with client hosting enabled. Require client placement, one client-owned guest at the fixture URL, zero server-owned guests, no remote-browser frame, a visible retained marker, and a browser.snapshot marker from the same remote page. Disable the setting and require that guest to survive; create another page and require one server-owned guest, zero matching client guests, and a visible server screencast. Delay renderer publication after tabCreate acknowledgement and require one exact bounded subscription to materialize the page without polling or cleanup. Reject a client create and require no server fallback. Omit placement or capabilities and require no client-host RPC. During replacement, admit close-first only for negotiated reconciliation of imported inventory; ordinary close-first and navigate-first remain rejected. Run the same placement oracle against headed and headless paired hosts, plus deterministic native, SSH, WSL, folder, worktree, reconnect, retirement, and browserless-host contracts.",
+ "commands": [
+ "pnpm exec playwright test tests/e2e/paired-client-hosted-browser.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts src/main/runtime/orca-runtime-browser-client-hosted.test.ts src/main/runtime/runtime-browser-client-page-creation.test.ts src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts src/main/runtime/browser-host-command-ledger.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-client-host-protocol.test.ts src/shared/remote-runtime-client.test.ts src/main/runtime/browser-host-capability-selection.test.ts src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts src/main/ipc/runtime-environment-browser-client-host-handler.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts",
+ "SKIP_BUILD=1 ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE=/Applications/Orca.app/Contents/MacOS/Orca pnpm exec playwright test tests/e2e/packaged-mixed-version-browser-placement.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/browser/browser-network-execution-route.test.ts src/main/browser/wsl-browser-network-execution-route.test.ts src/main/browser/wsl-browser-network-relay-launch.test.ts src/main/runtime/runtime-browser-network-execution-host.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-screencast-lifecycle.test.ts src/main/browser/browser-screencast-stream.test.ts src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts",
+ "ORCA_RUN_DOCKER_SSH_BROWSER_E2E=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts",
+ "Manual iOS 26.5 simulator: pair current mobile code to packaged Orca 1.4.184; create Browser; navigate to https://example.com; require one visible Example Domain tab on the server-hosted surface"
+ ],
+ "testFiles": [
+ "tests/e2e/paired-client-hosted-browser.spec.ts",
+ "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts",
+ "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts",
+ "src/main/runtime/orca-runtime-browser-client-hosted.test.ts",
+ "src/main/runtime/runtime-browser-client-page-creation.test.ts",
+ "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts",
+ "src/main/runtime/browser-host-command-ledger.test.ts",
+ "src/shared/browser-client-host-protocol.test.ts",
+ "src/shared/remote-runtime-client.test.ts",
+ "src/main/runtime/browser-host-capability-selection.test.ts",
+ "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts",
+ "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts",
+ "tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts",
+ "tests/e2e/packaged-mixed-version-browser-placement.spec.ts",
+ "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts",
+ "src/main/browser/paired-runtime-browser-network-route.test.ts",
+ "src/main/browser/browser-network-execution-route.test.ts",
+ "src/main/browser/wsl-browser-network-execution-route.test.ts",
+ "src/main/browser/wsl-browser-network-relay-launch.test.ts",
+ "src/main/runtime/runtime-browser-network-execution-host.test.ts",
+ "src/main/browser/browser-screencast-lifecycle.test.ts",
+ "src/main/browser/browser-screencast-stream.test.ts",
+ "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts",
+ "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "tests/e2e/paired-client-hosted-browser.spec.ts",
+ "assertions": [
+ "headed and headless product actions select one client placement with one viewing-desktop guest and no server duplicate",
+ "client pages render without a remote screencast and browser.snapshot reaches the exact retained guest",
+ "the kill switch preserves existing client pages and selects server placement only for the next page"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts",
+ "assertions": [
+ "eligible desktops prepare exact client placement while legacy and explicit-server requests omit placement",
+ "client preparation failure surfaces without server fallback",
+ "acknowledged client pages wait for exact renderer-store materialization"
+ ]
+ },
+ {
+ "file": "tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts",
+ "assertions": [
+ "a real stable release has no client-host or browser-tunnel capability constants",
+ "the old server schema strips additive placement without changing legacy fields",
+ "the current schema accepts the old request with placement omitted"
+ ]
+ },
+ {
+ "file": "tests/e2e/packaged-mixed-version-browser-placement.spec.ts",
+ "assertions": [
+ "an old packaged client omits placement and creates exactly one current-host guest with no client guest",
+ "a current client detects an old packaged host, keeps server placement, and creates no local guest",
+ "both skew directions return the exact server-hosted snapshot marker"
+ ]
+ },
+ {
+ "file": "src/main/runtime/browser-host-command-ledger.test.ts",
+ "assertions": [
+ "reconciliation may close imported inventory before a create command in the replacement ledger",
+ "ordinary close-first and navigate-first commands remain rejected"
+ ]
+ },
+ {
+ "file": "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts",
+ "assertions": [
+ "ssh2 and system OpenSSH resolve a container-only domain and localhost on the execution host"
+ ]
+ },
+ {
+ "file": "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts",
+ "assertions": [
+ "two independent viewers share one physical CDP screencast without evicting one another",
+ "viewer-specific backpressure and viewport ownership survive attach, detach, and replacement"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec playwright test tests/e2e/paired-client-hosted-browser.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "result": "passed",
+ "durationSeconds": 48.8,
+ "summary": "Two fresh-build production journeys passed against isolated headed Electron and real headless orca serve hosts, including client ownership, visible retained content, automation, no screencast, new-page-only kill switch, and server fallback without duplicate execution."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.23,
+ "summary": "Three assertions passed against published v1.4.182 and the current tree: the release lacks the negotiated capabilities, strips additive placement without changing legacy fields, and the current server preserves an old request with omitted placement."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE=/Applications/Orca.app/Contents/MacOS/Orca pnpm exec playwright test tests/e2e/packaged-mixed-version-browser-placement.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "result": "passed",
+ "durationSeconds": 10.3,
+ "summary": "Two packaged v1.4.184 skew journeys passed four times (9.6, 10.3, 9.5, and 10.3 seconds), including after the latest-main rebase: old client to current host and current client to old host each selected server placement, owned one host guest and zero client guests, and returned packaged-skew-marker through browser.snapshot."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "local",
+ "platform": "macos",
+ "command": "ORCA_RUN_DOCKER_SSH_BROWSER_E2E=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts",
+ "result": "passed",
+ "durationSeconds": 3.31,
+ "summary": "Two real Docker/OpenSSH routes resolved and loaded a container-only HTTP target through ssh2 and system SSH."
+ },
+ {
+ "date": "2026-08-16",
+ "runner": "manual",
+ "platform": "ios",
+ "command": "Manual iOS 26.5 simulator: pair current mobile code to packaged Orca 1.4.184; create Browser; navigate to https://example.com; require one visible Example Domain tab on the server-hosted surface",
+ "result": "passed",
+ "durationSeconds": 180,
+ "summary": "Current mobile code paired to an isolated legacy packaged host, created one Browser tab, and visibly progressed from about:blank through loading to Example Domain at https://example.com/ on the server-hosted mobile surface."
+ },
+ {
+ "date": "2026-08-17",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-screencast-lifecycle.test.ts src/main/browser/browser-screencast-stream.test.ts src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts",
+ "result": "passed",
+ "durationSeconds": 1.2,
+ "summary": "22 screencast lifecycle, stream, and shared-fanout tests passed; the suite confirms one physical CDP stream fans out independently to multiple viewers, preserves viewport ownership, and cleans up without cross-viewer eviction."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 300,
+ "scope": "fresh-build headed and headless paired Electron journeys plus deterministic placement, compatibility, reconnect, route, and Docker SSH contracts"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "The fresh headed/headless and deterministic matrices pass locally; CI and cross-platform soak history have not started for the activated cumulative tip."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "Before activation, production capability advertisement, client-host RPC registration, placement preparation, runtime client creation, logical publication, retained mounting, and automation routing were absent. During cumulative validation, delayed post-ack renderer publication caused the real paired journey to declare failure before the exact page materialized; the new deterministic delayed-publication test was red until a bounded exact Zustand subscription replaced the immediate check. Reconnect validation was then red 3 of 116 because the activation ordering guard rejected replacement-ledger close-first reconciliation for client-reported imported inventory; admitting only reconciliation close-first made the unchanged lifecycle package green 116 of 116 while a new unit oracle keeps ordinary close-first rejected. The paired tunnel integration was red 3 of 3 because its fixture duplicated methods now registered by the production registry; exercising ALL_RPC_METHODS directly is green 3 of 3. Packaged old/new skew and a current-mobile/legacy-host journey are now green; full candidate-revert evidence remains to be collected before promotion."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Placement preparation performs one fresh capability/status check and starts or reuses one exact browser host. Materialization uses one bounded exact store subscription with no polling and releases it on match or timeout. Each client page owns one retained non-reparented webview, one route/profile partition, one reference-counted execution-host grant, and bounded command/result state. Automation reuses the existing AgentBrowserBridge through one bounded negotiated envelope rather than duplicating browser methods. Headed and headless journeys each create exactly one client guest and one later server guest with no duplicate engine."
+ },
+ "promotionCriteria": [
+ "Run the same production placement and traffic-isolation oracle on physical Windows and Linux Electron hosts, including headed and headless where supported.",
+ "Capture real Electron HTTP, HTTPS, WebSocket, worker, redirect, speculative, DNS, WebRTC, and UDP/DoH behavior to prove no desktop-network fallback.",
+ "Complete fresh architecture, compatibility, lifecycle, security, performance, and test reviews on the rebased landing stack."
+ ],
+ "knownGaps": [
+ "Live production journeys are macOS-only; WSL is deterministic and SSH uses Docker Linux rather than physical Windows/Linux Electron.",
+ "The packaged skew journey uses one published old package plus the current E2E artifact on macOS; packaged Windows/Linux and a second independently released post-feature package remain unrecorded.",
+ "The live fixture proves routed HTTP, HTTPS, WebSocket, redirect, subresource, and download ownership plus remote DNS through the fixed SOCKS session; worker, speculative, WebRTC, UDP, and DoH containment remain unrecorded.",
+ "The setting currently requires explicit enablement while product rollout policy is decided; disabling it is intentionally new-page-only."
+ ],
+ "demotionRule": "Demote if placement changes after creation, a client failure creates a server fallback page, one logical page owns two engines, an omitted or unnegotiated field activates client hosting, the execution route resolves on the viewing desktop, a client page uses screencast/input RPC rendering, automation reaches a different page or engine, delayed publication leaks listeners or causes cleanup of a materialized page, or the kill switch mutates an existing page."
},
{
"id": "browser-stream.bounded-reconnect",
@@ -3525,6 +5550,129 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
+ {
+ "id": "terminal-session.shell-owned-mode-recovery",
+ "title": "Dead TUI mode recovery requires execution-host shell ownership",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-runtime",
+ "layer": "host-renderer-electron",
+ "surfaces": [
+ "alternate-screen lifecycle",
+ "mouse input routing",
+ "hidden-output snapshot restore",
+ "daemon reattach",
+ "remote-runtime replay",
+ "durable terminal history"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime", "wsl"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "daemon", "remote-runtime"],
+ "coverageNotes": "Deterministic host/parser, process-classifier, snapshot-publication, mixed-version, renderer, and macOS Electron coverage proves the local and shared paired-runtime contracts. Direct SSH intentionally publishes no shell proof and immediately preserves incumbent behavior. WSL, Windows ConPTY, Linux desktop, and paired headed/headless journeys remain gaps.",
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/16379"],
+ "invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.",
+ "oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts --reporter=dot",
+ "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot",
+ "pnpm exec electron-vite build --mode e2e",
+ "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
+ ],
+ "testFiles": [
+ "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts",
+ "src/main/daemon/terminal-shell-recovery-barrier.test.ts",
+ "src/main/daemon/session-shell-recovery.test.ts",
+ "src/main/daemon/session.test.ts",
+ "src/main/daemon/terminal-host-concurrent-create.test.ts",
+ "src/main/daemon/daemon-pty-adapter.test.ts",
+ "src/main/daemon/daemon-restore-scrollback-depth.test.ts",
+ "src/main/daemon/terminal-checkpoint-serializer.test.ts",
+ "src/main/providers/agent-foreground-process.test.ts",
+ "src/main/runtime/orca-runtime.test.ts",
+ "src/main/runtime/mobile-subscribe-integration.test.ts",
+ "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts",
+ "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts",
+ "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts",
+ "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/daemon/terminal-shell-recovery-barrier.test.ts",
+ "assertions": [
+ "an unclean-death boundary pauses the stream, injects the reset in order, and delivers the queued prompt after it",
+ "refuted proof, timeout, overflow, death, and disposal all flush the queue unmodified with no injection",
+ "clean alternate-screen exits prove ownership without pausing and later command starts revoke proof",
+ "a second unclean episode queued behind the first resolves in order",
+ "split triggers across emissions split the completing emission at the exact sequence boundary"
+ ]
+ },
+ {
+ "file": "src/main/daemon/session-shell-recovery.test.ts",
+ "assertions": [
+ "post-kill shell output survives into the normalized snapshot instead of the discarded alt buffer",
+ "a snapshot taken during a split escape keeps the pending tail intact and stale proof is revoked by the completing bytes"
+ ]
+ },
+ {
+ "file": "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts",
+ "assertions": [
+ "normal exit and cleanup-free SIGKILL both restore a visible clean shell marker",
+ "renderer logical buffer is normal and mouse tracking is disabled after host-proven recovery",
+ "real pointer input writes no SGR mouse report to the surviving shell",
+ "an unrelated pane remains live"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "result": "passed",
+ "durationSeconds": 33.8,
+ "summary": "Both normal-exit and SIGKILL journeys passed on the ordered-output-barrier implementation; each asserted renderer and host normal modes, host ownership metadata, exact PTY input, no post-exit SGR mouse report under real pointer input, and unrelated-pane survival, with post-boundary prompt output additionally proven visible on normal exit."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 90,
+ "scope": "focused host/renderer contracts, mixed-version journey, and two-case Electron run"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The deterministic focused suites and two-case Electron journey pass locally; CI soak history is not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The byte-identical Electron journey reproduced screenshot-like numeric mouse-report residue and a real SGR mouse packet reaching the shell before the focused action. With only the renderer's host-proven reset disabled, normal cleanup failed in normal-buffer/any-event mouse state and SIGKILL failed in alternate-buffer/any-event mouse state. Restoring the action made both cases green."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "No reveal timer, polling, periodic scan, broad session inventory, or renderer remount is added, and snapshots perform no writes. The stream pauses only for an unclean alternate-screen death (never for clean TUI exits or ordinary output), bounded by a 750 ms bail timer and a 256 KiB queue cap that both flush unmodified. The event-triggered fresh host inspection measured 30 local macOS samples at 84.6 ms median, 103.7 ms p95, and 124.5 ms maximum; the paired runtime asks the daemon for its settled verdict instead of running a second inspection."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive focused CI passes or 14 days of soak history.",
+ "Run the real journey on Linux and Windows, including ConPTY and WSL.",
+ "Add headed and headless paired-runtime journey evidence.",
+ "Keep missing-proof mixed-version behavior identical to the incumbent client path."
+ ],
+ "knownGaps": [
+ "A zero-width injected reset does not advance outputSequence, so the mobile renderer-snapshot stability oracle cannot detect a reset landing inside its serialize round-trip; the resulting tear is transient and self-heals on the next repaint (same exposure as the pre-existing device-attributes release emission).",
+ "Bytes the dying command printed before its OSC 133;D (pre-boundary output into the still-active alternate screen, e.g. a final panic line) are discarded with the dead frame; only post-boundary shell output is preserved.",
+ "Direct local provider and degraded (daemon-less) sessions get ownership proof and the renderer-side reset, but no in-stream normalization — pre-reveal prompt output is not preserved there.",
+ "Visible Electron evidence is macOS-only.",
+ "Direct SSH intentionally fails closed to current-main behavior because foreground proof would inherit transport timeout and enumeration ambiguity.",
+ "WSL and Windows ConPTY process-membership behavior has no physical-host proof yet.",
+ "Any stopped (Ctrl-Z) descendant in the shell's tree refutes the proof, so a suspended unrelated job silently disables recovery for that pane (fails closed to incumbent behavior).",
+ "Attach is deliberately settle-free: a viewer connecting inside an in-flight proof (~100ms typical) receives the pre-reset snapshot as its first frame and self-heals when the in-stream reset arrives; snapshot RPC and remote serialize paths still inherit a bounded (750ms) settle wait while a proof is in flight.",
+ "Paired headed and headless remote-runtime topologies have not yet run the full pointer-input oracle."
+ ],
+ "demotionRule": "Keep experimental or demote if missing ownership proof changes incumbent behavior, a live TUI loses mouse/focus modes, foreground inspection enters ordinary output/reveal paths, or the focused Electron journey flakes without an identified product or harness fault."
+ },
{
"id": "terminal-session.layout-pty-ownership",
"title": "Restored terminal layouts retain one renderer owner per PTY",
@@ -3773,14 +5921,16 @@
"providers": ["local", "daemon", "ssh", "runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
- "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
+ "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
"motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4327",
+ "https://github.com/stablyai/orca/pull/14590",
"https://github.com/stablyai/orca/pull/8628",
"https://github.com/stablyai/orca/pull/8706",
"https://github.com/stablyai/orca/issues/14524"
],
- "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
- "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
+ "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
+ "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
@@ -3826,6 +5976,7 @@
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"assertions": [
"final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout",
+ "final-pane externally owned teardown commits session retirement before PTY stop, tolerates duplicate exit retirement, and preserves an unrelated live canary",
"post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive",
"renderer reload with the same provider-attested PTY incarnation preserves the exact handle",
"same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale",
@@ -4006,7 +6157,7 @@
},
"redGreenEvidence": {
"status": "complete",
- "evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2."
+ "evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2. The 1.4.187 recurrence oracle deterministically failed with historical tab_not_found when PTY stop was moved ahead of durable retirement, then passed with retirement committed before externally owned teardown while the canary remained live."
},
"performanceBudget": {
"required": true,
@@ -4495,7 +6646,7 @@
},
{
"id": "agent-status.manual-compact-identity",
- "title": "Manual Claude compact completion retires only its exact status generation",
+ "title": "A finished Claude compact clears the pane, and an aborted one never strands it",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
@@ -4510,19 +6661,19 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
- "coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
+ "coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Behavior tests deliver only events present in CLAUDE_EVENTS, so a mapping that is not registered cannot pass -- the failure mode that let the earlier compact correlation ship unreachable. Claude Code 2.1.227 PTY captures prove a successful manual compact emits PreCompact, a start-less summarizer SubagentStop, SessionStart(source=compact), then PostCompact, that an aborted compact emits PreCompact ALONE, that /compact emits no UserPromptSubmit, and that session_id is stable across the window. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11352",
"https://github.com/stablyai/orca/pull/11353"
],
- "invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
- "oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
+ "invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.",
+ "oracle": "Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the consumed-compact map untouched. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require the consumed-compact map to be empty after each pane-scoped teardown route and to follow a pane move. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.",
"commands": [
- "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
- "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
+ "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both"
],
"testFiles": [
- "src/shared/manual-compact-prompt-identity.test.ts",
+ "src/main/claude/compact-status-registration.test.ts",
"src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
"src/shared/agent-hook-listener-claude-turn-state.test.ts",
@@ -4535,19 +6686,29 @@
],
"assertionRefs": [
{
- "file": "src/shared/manual-compact-prompt-identity.test.ts",
+ "file": "src/main/claude/compact-status-registration.test.ts",
"assertions": [
- "matching manual identity settles once",
- "stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
- "4,097 rejected pane keys allocate no ownership state"
+ "PostCompact is registered in CLAUDE_EVENTS and written into the settings Claude reads, and PreCompact is absent from both",
+ "the measured successful manual compact ends a stuck working row as a done with sessionBoundary",
+ "an aborted compact (PreCompact alone) leaves an idle pane idle and a working pane working",
+ "an automatic completion claims nothing, writes no compact-scoped state, and the turn's own Stop still closes it",
+ "a completion retires a child that exists only as a disk snapshot, but a runtime child or an active session cron still holds the pane and the completion then publishes nothing at all",
+ "a completion clears a restored row -- delivered while that hydrated row is still the cached owner -- despite a stale connection id or a missing provider session, but never resurrects a retired pane",
+ "absent prompt UUID, foreign provider session, and duplicate delivery fail closed",
+ "the consumed-compact record is dropped by clearPaneCacheState and clearAllListenerCaches and follows movePaneCacheState"
]
},
{
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"assertions": [
- "local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
- "later provider work and wrong transport identity remain authoritative",
- "persisted PreCompact restores its prompt"
+ "the measured local HTTP stream settles once and rejects a duplicate completion",
+ "a hydrated restart-stuck pane reaches done with an empty roster, and a pane a runtime child owns emits nothing and keeps its row object identical",
+ "the relay forwards the completion and preserves its compact identity in the replay cache so the client re-runs ownership before applying the clearing row",
+ "a relay with a cold cache still forwards the completion, and the client still refuses to mint a row it never had",
+ "a legacy relay's manual envelope is stamped as a session boundary and its automatic envelope is dropped instead of minting working",
+ "a promptless completion keeps the summarized turn's label with and without a trigger on the envelope",
+ "a trigger-stripped replay is classified by payload state, leaves a FINISHED pane finished, and is still rejected on an owner mismatch",
+ "a PreCompact envelope never drives pane state"
]
},
{
@@ -4563,22 +6724,22 @@
],
"evidenceRuns": [
{
- "date": "2026-08-02",
+ "date": "2026-08-18",
"runner": "local",
"platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
"result": "passed",
- "durationSeconds": 2.66,
- "summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
+ "durationSeconds": 3.28,
+ "summary": "Ten files and 116 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files to the merge base first turned 12 of 21 registration-gated tests and 8 of 12 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code. Every assertion was additionally mutation-tested: 18 of 18 targeted mutations -- including reaping runtime children as well as snapshot ones, restating a pane live evidence still holds, re-enforcing compact ownership on the relay, and dropping the consumed-compact map from any one teardown route -- are caught."
},
{
- "date": "2026-08-02",
+ "date": "2026-08-17",
"runner": "manual",
"platform": "macos",
- "command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
+ "command": "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both",
"result": "passed",
- "durationSeconds": 120,
- "summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
+ "durationSeconds": 1080,
+ "summary": "Claude Code 2.1.227. Aborted compact emitted PreCompact(manual) ALONE with no PostCompact and no SessionStart(compact). Successful compact emitted PreCompact(manual), a start-less SubagentStop (agent_type empty, no matching SubagentStart), SessionStart(source=compact), PostCompact(manual), then Notification(idle_prompt). /compact emitted no UserPromptSubmit in either the TUI or print mode; session_id was stable across the whole window while the compact allocated its own prompt_id."
}
],
"runtimeBudget": {
@@ -6450,6 +8611,7 @@
"hidden remote worktree retention",
"remote terminal reveal and input",
"paired host relaunch with preserved daemon PTYs",
+ "paired host session inventory publication authority",
"manual server disconnect"
],
"platforms": ["macos", "linux", "windows"],
@@ -6461,13 +8623,14 @@
"https://github.com/stablyai/orca/issues/8652",
"https://github.com/stablyai/orca/pull/10625"
],
- "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
+ "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. An empty headed session-tab inventory is authoritative only after the current renderer graph generation publishes a complete inventory and every execution host answers the PTY census; unpublished, resync-pending, transport-failed, and partially scoped inventories remain unverifiable, while genuinely authoritative empty and non-empty inventories settle and a scope-less legacy empty keeps its pre-authority best-effort settle so outdated hosts retain agent auto-resume. The unpublished-empty regression must keep the resume dispatch parked with exactly one daemon process/writer; restoring unconditional empty hydration must release one dispatch and create two live writers. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
@@ -6500,6 +8663,11 @@
"src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts",
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
+ "src/main/runtime/session-tabs-inventory-publication.test.ts",
+ "src/main/runtime/rpc/methods/session-tabs.test.ts",
+ "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts",
+ "src/main/daemon/terminal-host-agent-session.test.ts",
+ "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts",
"src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
"src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
"src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts",
@@ -6583,6 +8751,14 @@
"file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"assertions": ["retries an existing subscriber when provider inventory becomes ready"]
},
+ {
+ "file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts",
+ "assertions": [
+ "unpublished empty inventory keeps resume dispatch at zero with the original daemon session, process id, and writer as the sole live writer",
+ "an authoritative empty inventory settles the mirror and releases exactly one resume dispatch with zero liveness probes",
+ "re-enabling unconditional empty hydration makes the identical oracle red with one resume dispatch, two session ids, two process ids, and two writer calls"
+ ]
+ },
{
"file": "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
"assertions": [
@@ -8140,6 +10316,7 @@
"remote-runtime host surface materialization",
"remote-runtime mirror polling",
"remote-runtime network recovery",
+ "paired client sleep/wake reconnect",
"terminal create idempotency",
"provider listing",
"reattach",
@@ -8152,6 +10329,8 @@
"coveredProviders": ["ssh", "remote-runtime"],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"motivatingLinks": [
+ "https://linear.app/stably/issue/STA-3107",
+ "https://github.com/stablyai/orca/pull/12664",
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/6979",
@@ -8163,6 +10342,7 @@
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
@@ -8177,6 +10357,8 @@
],
"testFiles": [
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
+ "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
+ "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
@@ -8235,6 +10417,20 @@
"tests/e2e/ssh-docker-relay-perf.spec.ts"
],
"assertionRefs": [
+ {
+ "file": "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
+ "assertions": [
+ "six panes sharing one dropped multiplex connection each resubscribe to a distinct live host handle",
+ "a pane parked while the client slept is authoritatively activated and rematerialized without leaving any pane handle null"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
+ "assertions": [
+ "after a long sleep every live host PTY and tab retains its client-owned sidebar agent row",
+ "unwritten or explicitly released pane authority still cedes deletion to the host"
+ ]
+ },
{
"file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"assertions": [
@@ -8564,6 +10760,15 @@
}
],
"evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
+ "result": "passed",
+ "durationSeconds": 2.54,
+ "summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake."
+ },
{
"date": "2026-07-14",
"runner": "local",
@@ -8647,7 +10852,7 @@
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
+ "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. For STA-3107, reverting #11542 in the six-pane multiplex harness leaves exactly one parked pane without a resubscribed handle while its siblings recover; before #12664, the first post-sleep host snapshot erases every stale client-owned sidebar row despite live tabs and host PTYs. The deterministic contracts pass after host activation rematerializes the missing pane and client-written ownership, rather than freshness, controls row retention. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
},
"performanceBudget": {
"required": true,
@@ -9402,7 +11607,7 @@
"invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.",
"oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
"commands": [
- "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.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-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.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-active-worktree-terminal-creation.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 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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.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-active-worktree-terminal-creation.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts"
],
@@ -9417,7 +11622,9 @@
"src/main/ipc/pty-runtime-kill-and-exit.test.ts",
"src/main/providers/agent-foreground-process.test.ts",
"src/main/providers/local-pty-provider-foreground-process.test.ts",
- "src/main/providers/windows-conpty-process-membership.test.ts",
+ "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts",
+ "src/main/providers/windows-console-attached-processes.test.ts",
+ "src/main/providers/windows-pty-job-membership.test.ts",
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
"src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts",
@@ -9456,13 +11663,30 @@
]
},
{
- "file": "src/main/providers/windows-conpty-process-membership.test.ts",
+ "file": "src/main/providers/windows-console-attached-processes.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/windows-pty-job-membership.test.ts",
+ "assertions": [
+ "never spawns a child process to answer",
+ "refuses an answer that does not contain the shell",
+ "is asked with the pty handle, because a bare pid cannot find the job"
+ ]
+ },
+ {
+ "file": "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts",
+ "assertions": [
+ "stops holding a dead agent once the job answer is only a superset",
+ "restores the idle refresh backoff once the dead identity is gone",
+ "never expires an identity while scans stay degraded",
+ "never expires an identity while the job answer is unverifiable"
+ ]
+ },
{
"file": "src/main/providers/local-pty-provider-foreground-process.test.ts",
"assertions": [
@@ -9610,13 +11834,13 @@
],
"evidenceRuns": [
{
- "date": "2026-07-10",
+ "date": "2026-08-25",
"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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.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-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.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-active-worktree-terminal-creation.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",
+ "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.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-active-worktree-terminal-creation.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."
+ "durationSeconds": 3.88,
+ "summary": "Twenty-six focused test files passed (489 tests) on the PR head, 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": {
@@ -9675,7 +11899,7 @@
"oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. 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/quarter-circle-title-send-authorization.test.ts",
- "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.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-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
+ "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-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-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
],
"testFiles": [
"src/main/runtime/quarter-circle-title-send-authorization.test.ts",
@@ -9684,7 +11908,9 @@
"src/main/ipc/pty-runtime-kill-and-exit.test.ts",
"src/main/providers/agent-foreground-process.test.ts",
"src/main/providers/local-pty-provider-foreground-process.test.ts",
- "src/main/providers/windows-conpty-process-membership.test.ts",
+ "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts",
+ "src/main/providers/windows-console-attached-processes.test.ts",
+ "src/main/providers/windows-pty-job-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-explicit-target.test.ts",
@@ -9735,7 +11961,7 @@
"assertions": ["fresh confirmation is discarded when its owning local PTY exits"]
},
{
- "file": "src/main/providers/windows-conpty-process-membership.test.ts",
+ "file": "src/main/providers/windows-console-attached-processes.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"
@@ -9776,13 +12002,13 @@
"summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles."
},
{
- "date": "2026-07-11",
+ "date": "2026-08-25",
"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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.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-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx",
+ "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-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts src/main/providers/windows-console-attached-processes.test.ts src/main/providers/windows-pty-job-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-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/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."
+ "durationSeconds": 13.74,
+ "summary": "Twelve focused test files passed (1351 tests, 1 skipped), 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": {
@@ -9971,22 +12197,27 @@
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
- "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
+ "coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
"motivatingLinks": [
+ "https://linear.app/stably/issue/STA-3800",
+ "https://linear.app/stably/issue/STA-3904",
+ "https://linear.app/stably/issue/STA-4328",
"https://github.com/stablyai/orca/issues/7226",
"https://github.com/stablyai/orca/issues/13821",
"https://github.com/stablyai/orca/issues/14347"
],
- "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
- "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
+ "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
+ "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
],
"testFiles": [
"src/shared/agent-prompt-injection.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
+ "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
"src/main/runtime/orchestration/coordinator.test.ts",
"tests/tools/repro-orchestration-long-prompt.mjs"
],
@@ -10021,6 +12252,13 @@
"failed prompt injection rolls back the active dispatch"
]
},
+ {
+ "file": "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
+ "assertions": [
+ "delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts",
+ "a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter"
+ ]
+ },
{
"file": "src/main/runtime/orchestration/coordinator.test.ts",
"assertions": [
@@ -10038,6 +12276,15 @@
}
],
"evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
+ "result": "passed",
+ "durationSeconds": 21.84,
+ "summary": "Two deterministic worker-start RPC contracts passed with fake clocks and reopened SQLite receipts for one accepted turn and one swallowed-Enter stalled outcome."
+ },
{
"date": "2026-08-14",
"runner": "local",
@@ -10094,7 +12341,7 @@
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
+ "evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The worker-start negative controls caught both premature submission before composer readiness and a false-ready swallowed Enter; the accepted candidate records one started turn, while the swallowed arm records agent_prompt_stalled without a rescue Enter. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
},
"performanceBudget": {
"required": true,
@@ -10912,16 +13159,18 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
- "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
+ "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, host-partition persistence and legacy retirement replay with an absent web-terminal layout map, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/12355",
"https://github.com/stablyai/orca/issues/13860",
"STA-905",
- "STA-3932"
+ "STA-3932",
+ "STA-5122"
],
- "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
- "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
+ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Exact terminal-close persistence must settle when a host partition omits renderer-owned layout state. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
+ "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Rebase a closed web-terminal host partition without terminalLayoutsByTabId and require the persistence write to complete while preserving host-authoritative membership; replay a valid legacy retirement under the same omission and require exact membership removal plus revision advancement. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
"commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
@@ -10929,6 +13178,7 @@
],
"testFiles": [
"src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts",
+ "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
@@ -10938,6 +13188,13 @@
"tests/e2e/orchestration-worker-settlement-release-cli.spec.ts"
],
"assertionRefs": [
+ {
+ "file": "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts",
+ "assertions": [
+ "rebases a host partition that no longer carries the closed web-terminal layout",
+ "replays a legacy retirement when its host partition omits terminal layouts"
+ ]
+ },
{
"file": "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts",
"assertions": [
@@ -10991,6 +13248,15 @@
}
],
"evidenceRuns": [
+ {
+ "date": "2026-08-27",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
+ "result": "passed",
+ "durationSeconds": 8.78,
+ "summary": "Seven deterministic files passed 78 tests, including red-green host-partition rebase and legacy-retirement regressions with an absent web-terminal layout map plus exact lease, reuse, takeover, recovery, restart, archive, and accounting contracts."
+ },
{
"date": "2026-08-12",
"runner": "local",
@@ -11029,11 +13295,11 @@
},
"redGreenEvidence": {
"status": "complete",
- "evidence": "The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls."
+ "evidence": "The version-skew legacy-retirement test deterministically threw at mobile-session-terminal-persistence-retirement.ts:75 before the null-safe layout read and passed with exact tab removal, tombstone cleanup, and topology-revision advancement after the fix. The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls."
},
"performanceBudget": {
"required": true,
- "evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Worker-list uses two set queries rather than one resource lookup per worker."
+ "evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. Missing layout maps use constant-time empty-record fallbacks inside the existing explicit persistence pass, with no added scan or allocation proportional to terminal history. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Worker-list uses two set queries rather than one resource lookup per worker."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
@@ -11044,7 +13310,7 @@
"knownGaps": [
"Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.",
"Federated release remains explicitly unsupported and retained.",
- "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised; the current fix changes provider inventory classification and SQLite state only, not ConPTY, filesystem, update, sleep, firewall, or window lifecycle semantics."
+ "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised; the host-partition fix changes persistence normalization only, not ConPTY, filesystem, update, sleep, firewall, or window lifecycle semantics."
],
"demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect."
},
@@ -11502,7 +13768,7 @@
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"commands": [
- "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
"pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
@@ -11516,6 +13782,7 @@
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
"src/shared/terminal-query-reply.test.ts",
"src/shared/pty-startup-ingress-live-query-reply.test.ts",
+ "src/shared/pty-startup-reply-echo-shapes.test.ts",
"tests/e2e/terminal-osc-color-queries.spec.ts",
"tests/e2e/terminal-typing-latency.spec.ts",
"tests/e2e/pty-input-write-queue-ssh.spec.ts"
@@ -11594,13 +13861,13 @@
],
"evidenceRuns": [
{
- "date": "2026-08-09",
+ "date": "2026-08-25",
"runner": "local",
"platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"result": "passed",
- "durationSeconds": 2.35,
- "summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
+ "durationSeconds": 1.05,
+ "summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
},
{
"date": "2026-08-09",
@@ -12377,7 +14644,7 @@
"id": "terminal-render.pixel-refresh-repair",
"title": "Forced repaint does not change revealed pane pixels",
"maturity": "experimental",
- "protection": "none",
+ "protection": "partial",
"owner": "terminal-rendering",
"layer": "electron-visual-golden",
"surfaces": [
@@ -12389,47 +14656,207 @@
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon"],
- "coveredPlatforms": [],
- "coveredProviders": [],
- "coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "A headed macOS Electron test drives explicit destructive atlas recovery while DEC 2026 output is held, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7133",
"https://github.com/stablyai/orca/pull/7054"
],
- "invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.",
- "oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
- "commands": [],
- "testFiles": [],
- "assertionRefs": [],
- "evidenceRuns": [],
+ "invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.",
+ "oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
+ "commands": [
+ "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
+ "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
+ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
+ "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
+ "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
+ "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
+ "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
+ "tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
+ "assertions": [
+ "deduplicates a deferred dpr retry and consumes it after repair succeeds",
+ "caps a deferred dpr transition at sixteen animation-frame retries"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
+ "assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"]
+ },
+ {
+ "file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
+ "assertions": [
+ "repairs when the canvas and renderer dimensions share a stale dpr cache",
+ "defers an unmeasurable WebGL canvas but accepts a renderer-less pane"
+ ]
+ },
+ {
+ "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
+ "assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
+ "result": "passed",
+ "durationSeconds": 2,
+ "summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup."
+ },
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
+ "result": "passed",
+ "durationSeconds": 60,
+ "summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus pixel-diff metadata."
+ },
+ {
+ "date": "2026-08-22",
+ "runner": "local",
+ "platform": "macos",
+ "command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3",
+ "result": "passed",
+ "durationSeconds": 48,
+ "summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow."
+ }
+ ],
"runtimeBudget": {
- "p95Seconds": 300,
- "scope": "release-blocking golden Electron visual test"
+ "p95Seconds": 120,
+ "scope": "focused prebuilt headed Electron visual test"
},
"flakeHistory": {
- "status": "not-started",
- "evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml."
+ "status": "unknown",
+ "evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
},
"redGreenEvidence": {
- "status": "missing",
- "evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires."
+ "status": "complete",
+ "evidence": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle passes the 1.5% material-diff threshold."
},
"performanceBudget": {
"required": true,
- "evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening."
+ "evidence": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames."
},
"promotionCriteria": [
- "Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.",
"Establish a stable material-diff threshold across platforms via nightly runs.",
"Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.",
- "Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command."
+ "Add Linux and Windows GPU evidence or retain those platform gaps explicitly."
],
"knownGaps": [
- "No manifest command yet.",
- "Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest."
+ "The deterministic recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.",
+ "The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.",
+ "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded."
],
"demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners."
},
+ {
+ "id": "terminal-render.synchronized-reveal-coherence",
+ "title": "Ordinary reveal preserves synchronized-output frame coherence",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-rendering",
+ "layer": "electron-visual-golden",
+ "surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/pull/7133",
+ "https://github.com/stablyai/orca/pull/7054"
+ ],
+ "invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.",
+ "oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.",
+ "commands": [
+ "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
+ "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
+ "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
+ "tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
+ "assertions": [
+ "schedules an atlas-preserving present on a light tab reveal",
+ "keeps heavy atlas recovery off a plain window refocus"
+ ]
+ },
+ {
+ "file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
+ "assertions": ["preserves a synchronized frame on an ordinary reveal present"]
+ },
+ {
+ "file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
+ "assertions": [
+ "routes synchronized output through RenderService instead of the renderer",
+ "releases observer pause before requesting the synchronized frame"
+ ]
+ },
+ {
+ "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
+ "assertions": [
+ "@headful reveal preserves the coherent frame until synchronized output releases"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
+ "result": "passed",
+ "durationSeconds": 53,
+ "summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 120,
+ "scope": "focused prebuilt headed Electron prevention and watchdog test"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames."
+ },
+ "promotionCriteria": [
+ "Accumulate stable macOS runs for both normal release and watchdog release.",
+ "Add Linux and Windows GPU evidence or retain those platform gaps explicitly.",
+ "Keep the destructive atlas-recovery oracle separate and passing."
+ ],
+ "knownGaps": [
+ "The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.",
+ "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.",
+ "The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately."
+ ],
+ "demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic."
+ },
{
"id": "pty-delivery.renderer-lifecycle-accounting-reset",
"title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze",
@@ -14182,6 +16609,471 @@
"The legacy remote-uploads root is intentionally not reclaimed while a mixed-version relay may still own bytes there; eventual legacy-root retirement requires a separate compatibility decision."
],
"demotionRule": "Demote if one live owner can remove another owner's bytes, cleanup exceeds 64 inspected entries per attempt, a lifecycle path retains its exact owned archive after settlement, or the process oracle flakes without an identified harness or product defect."
+ },
+ {
+ "id": "terminal-startup.quick-command-pre-bind-recovery",
+ "title": "Quick Command survives a pre-bind terminal recovery",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-renderer-lifecycle",
+ "layer": "electron-ui-and-real-local-pty",
+ "surfaces": ["Quick Commands", "terminal pane recovery", "fresh PTY startup"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "A forced pre-spawn barrier makes one visible Quick Command cross a recovery remount before its successor PTY binds. Store and lifecycle contracts retain lower-layer ownership; the Electron sentinel adds the rendered affordance, real PTY, exact-once terminal marker, staged identity file, and unrelated sibling canary.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4876",
+ "https://github.com/stablyai/orca/pull/15630"
+ ],
+ "invariant": "A Quick Command selected for a new terminal remains owned by that tab through a recovery remount, executes exactly once only after the successor PTY binds, and never reaches an unrelated terminal.",
+ "oracle": "Arm the E2E-only pre-spawn barrier, click the visible Quick Command, force one recovery generation while the original spawn is blocked, and release it. Require one exact semantic xterm marker, an independently written pane/tab identity file matching the bound live PTY, queue consumption only after bind, and a live interactive sibling canary.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/store/slices/terminal-startup-command-retention.test.ts --reporter=dot",
+ "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts",
+ "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
+ "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
+ "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
+ "assertions": ["recovery remount retains the exact queued startup command"]
+ },
+ {
+ "file": "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts",
+ "assertions": [
+ "the visible Quick Command executes exactly once in the intended fresh PTY",
+ "queue consumption follows PTY binding and the sibling terminal remains live"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "durationSeconds": 23,
+ "summary": "The exact rebased PR head passed the headless Electron flow in 23.0 seconds after an E2E-mode build."
+ },
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+ "durationSeconds": 24,
+ "summary": "The exact rebased PR head passed the visible-window Electron flow in 23.9 seconds using the same E2E build."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 90,
+ "scope": "one isolated Electron app, one forced recovery remount, and two local PTYs"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The new sentinel has local headless and forced-headful evidence but no routed CI soak history."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The byte-identical spec failed with pending:null before PTY bind when the pre-6e25a900 eager-consume behavior was restored, then passed after restoration."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "The barrier exists only in E2E builds, adds no production timer or polling, and the live sentinel completed in under 20 seconds locally."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive routed CI passes over at least 14 days without an unexplained flake.",
+ "Keep retries at zero and retain both semantic and identity oracles.",
+ "Collect Linux Electron evidence before promoting the focused lane to blocking."
+ ],
+ "knownGaps": [
+ "Windows, Linux, SSH, WSL, and paired-runtime topologies are not covered because they were not causal to STA-4876.",
+ "The sentinel remains advisory while the repository-wide E2E signal is red."
+ ],
+ "demotionRule": "Demote if the barrier can be bypassed nondeterministically, either independent oracle is removed, the sibling canary is no longer isolated, or one unexplained flake occurs."
+ },
+ {
+ "id": "quick-open.paired-host-path-search",
+ "title": "Paired Quick Open searches bounded host paths",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "runtime-filesystem",
+ "layer": "host-file-search-and-paired-runtime",
+ "surfaces": ["Quick Open", "paired desktop runtime", "headless runtime host"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "ssh", "paired-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "paired-runtime"],
+ "coverageNotes": "Focused contracts cover bounded host search, ignore traversal, RPC response bounds, renderer cancellation, deterministic ordering, and transport payload size. Headed and headless paired-runtime journeys use an 88,763-file fixture and are source-routed into advisory PR E2E; physical SSH and Windows remain gaps.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-4354",
+ "https://github.com/stablyai/orca/pull/15158"
+ ],
+ "invariant": "Quick Open queries execute on the worktree host, find exact ignored-path matches beyond the legacy inventory prefix, return deterministic bounded results, and keep the paired runtime usable without overflowing its outbound reply buffer.",
+ "oracle": "Create 88,763 paths with 77,792 ignored entries and a nominal 22 GiB sparse file, query exact late gitignored and .orcaignored targets through headed and headless paired runtimes, and require one deterministic match, a payload below 4 KiB, and a healthy follow-up worktree call.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
+ "pnpm exec playwright test tests/e2e/paired-quick-open-large-tree.spec.ts --config tests/playwright.config.ts --workers=1"
+ ],
+ "testFiles": [
+ "src/main/ipc/filesystem-search-file-paths.test.ts",
+ "src/main/runtime/orca-runtime-files-search.test.ts",
+ "src/main/runtime/rpc/methods/files-path-search.test.ts",
+ "src/renderer/src/components/quick-open-file-list.react.test.tsx",
+ "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
+ "src/shared/quick-open-transport-budget.test.ts",
+ "tests/e2e/paired-quick-open-large-tree.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/ipc/filesystem-search-file-paths.test.ts",
+ "assertions": [
+ "host search stays bounded and respects gitignore plus .orcaignore traversal"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
+ "assertions": ["paired path queries stay deterministic and bounded across transport"]
+ },
+ {
+ "file": "tests/e2e/paired-quick-open-large-tree.spec.ts",
+ "assertions": ["headed and headless hosts find late ignored targets and remain callable"]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
+ "durationSeconds": 5,
+ "summary": "Six focused files passed 52 tests across host search, runtime RPC, renderer query ownership, and transport bounds; existing React act warnings remained non-fatal."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 300,
+ "scope": "focused deterministic contracts plus two serial paired Electron large-tree journeys"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The sentinel is newly source-routed for PR collection; no route-specific soak history exists yet."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The merged STA-4354 oracle places both targets beyond the removed inventory prefix; the old client-side prefix inventory misses them, while host search returns the exact deterministic paths."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Search is query-driven, result-limited, cancellation-aware, and payload-bounded; PR routing selects this fixture only for its exact production authorities or direct spec edits."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
+ "Collect physical SSH and Windows paired-runtime evidence.",
+ "Keep deterministic result hashes, payload bounds, and post-query runtime health green."
+ ],
+ "knownGaps": [
+ "The live large-tree evidence uses local headed and headless hosts rather than physical SSH.",
+ "Windows path-search tooling and filesystem behavior are not represented by the local evidence."
+ ],
+ "demotionRule": "Demote if the routed fixture flakes without a diagnosed product or harness cause, returns unbounded payloads, misses either late ignored target, or leaves the paired runtime unhealthy."
+ },
+ {
+ "id": "terminal-session.host-cold-park-stream-continuity",
+ "title": "Host cold parking preserves paired terminal streams",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-runtime-graph",
+ "layer": "renderer-runtime-graph-and-terminal-stream",
+ "surfaces": ["host terminal cold park", "paired remote viewer", "multi-pane runtime graph"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local-daemon", "ssh-daemon", "paired-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local-daemon", "paired-runtime"],
+ "coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, and disposal behavior. One headed paired journey proves input and echoed output while the host pane remains cold-parked.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-2854",
+ "https://github.com/stablyai/orca/pull/15514"
+ ],
+ "invariant": "Cold parking a host renderer pane never retires its live PTY's runtime-graph leaf or interrupts a paired subscriber's stream, input, reconnect, pane identity, or multi-pane routing; the leaf retires only when exact PTY ownership ends.",
+ "oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
+ "pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
+ "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
+ "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
+ "tests/e2e/host-parked-pane-remote-viewer.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
+ "assertions": [
+ "a live parked watcher publishes the exact leaf, pane runtime ID, and title",
+ "multi-pane publication never names an unsent active leaf and exact disposal retires ownership"
+ ]
+ },
+ {
+ "file": "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
+ "assertions": [
+ "a fully unmounted host renderer still streams, accepts input, and resubscribes"
+ ]
+ },
+ {
+ "file": "tests/e2e/host-parked-pane-remote-viewer.spec.ts",
+ "assertions": [
+ "a cold-parked host pane carries a complete paired-client input and echo round trip"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
+ "durationSeconds": 8,
+ "summary": "Three focused files passed 64 watcher, multi-pane runtime-graph, cold-park policy, and authoritative-stream tests."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 180,
+ "scope": "focused deterministic contracts plus one serial headed paired cold-park journey"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The existing sentinel is newly source-routed for PR collection; route-specific CI history has not started."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Publication reads the existing bounded parked-watcher registry and adds no timer, polling, provider scan, subprocess, or wire field; the 90-second live timeline is selected only by exact parking and graph authorities."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
+ "Collect Linux, Windows, WSL, and physical SSH cold-park evidence.",
+ "Keep exact per-leaf disposal, multi-pane identity, and full client round-trip assertions green."
+ ],
+ "knownGaps": [
+ "The live paired cold-park journey is macOS-only and uses a local daemon PTY.",
+ "Physical SSH, WSL, Linux, Windows, and host-restart cold-park journeys are not recorded."
+ ],
+ "demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause."
+ },
+ {
+ "id": "agent-browser.owner-boundary-cleanup",
+ "title": "Headless browser helpers retire with their owning page and runtime",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "browser-runtime",
+ "layer": "electron-main-headless-browser-lifecycle",
+ "surfaces": [
+ "headless offscreen browser page close",
+ "offscreen renderer destruction",
+ "app quit",
+ "headless serve SIGINT/SIGTERM"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "remote-runtime", "ssh", "wsl"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "The owner-boundary contract injects an isolated BrowserManager, offscreen WebContents, and AgentBrowserBridge. It proves explicit close removes the page from command routing before awaiting exact named-session retirement, runs retirement on unexpected renderer destruction, preserves unrelated pages, joins pending creation/retirement during shutdown, rejects command admission after terminal cleanup begins, retries Electron quit after a vetoed signal, preserves Windows shared-console graceful Ctrl-C, reserves the full renderer-acknowledgement and committed-teardown budgets before supervisor force-kill, bounds process-swap retirement to five seconds, and caps cleanup concurrency at four. A built-CLI macOS headless serve run used an isolated profile, folder workspace, socket directory, and port: exact PPID-1 helper/session identity disappeared within five seconds after page close and after Ctrl-C runtime shutdown, while an unrelated page survived close and reconnect. Linux cgroup and paired/SSH journeys remain gaps.",
+ "motivatingLinks": [
+ "https://linear.app/stably/issue/STA-5400",
+ "https://github.com/stablyai/orca/issues/16367"
+ ],
+ "invariant": "For each Orca-owned page identity, after its owner closes or is destroyed and a five-second third-party shutdown grace expires, no live helper session named orca-tab- may remain; unrelated live page identities must survive, and runtime quit must join the bounded cleanup attempt. The serve supervisor may force-kill only after the renderer-acknowledgement deadline, committed teardown deadline, and bounded scheduling margin have all elapsed.",
+ "oracle": "Create two isolated offscreen pages and register their WebContents. Close one, block onPageClosed for its stable page ID, and require unregisterGuest to have already removed that page from command routing while the unrelated page remains live. Emit destroyed and require the same exact retirement. Race shutdown with creation, pending retirement, and process-swap destruction; require no replacement session or command after terminal cleanup starts, require shutdown to remain pending until retirement settles, require the swap close command to use the five-second cleanup timeout, and require at most four concurrent close commands. Deliver repeated signals and require every attempt to reach Electron quit; under Windows shared-console semantics require the child to handle Ctrl-C without an immediate child.kill. Advance the supervisor clock through the renderer-acknowledgement and committed teardown deadlines and require no SIGKILL until the bounded scheduling margin elapses. In direct built-CLI serve, inspect exact session/PID/socket identity plus RSS/fd inventory before close and after a five-second grace; reconnect between commands and stop via Ctrl-C. No process-name kill or global sweep is permitted.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser"
+ ],
+ "testFiles": [
+ "src/main/browser/offscreen-browser-backend-lifecycle.test.ts",
+ "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts",
+ "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
+ "src/main/startup/serve-signal-handlers.test.ts",
+ "src/main/startup/desktop-startup-ordering.test.ts",
+ "src/cli/runtime/serve-signal-exit-diagnostic.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/browser/offscreen-browser-backend-lifecycle.test.ts",
+ "assertions": [
+ "explicit close unregisters the page before awaiting exact owner cleanup while unrelated and same-ID replacement pages survive",
+ "unexpected offscreen renderer destruction invokes and joins exact helper-owner cleanup",
+ "backend shutdown joins every pending owner cleanup with concurrency capped at four"
+ ]
+ },
+ {
+ "file": "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts",
+ "assertions": [
+ "owner cleanup uses a five-second close-command timeout",
+ "process-swap retirement uses the same five-second cleanup timeout",
+ "runtime shutdown includes sessions whose creation is still pending",
+ "runtime shutdown rejects late command and replacement-session admission",
+ "runtime-wide helper cleanup concurrency is capped at four"
+ ]
+ },
+ {
+ "file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
+ "assertions": ["closing a tab retires the exact named agent-browser session"]
+ },
+ {
+ "file": "src/main/startup/serve-signal-handlers.test.ts",
+ "assertions": [
+ "every repeated serve signal retries Electron quit after a possible renderer veto",
+ "SIGINT and SIGTERM listeners remain installed during quit draining"
+ ]
+ },
+ {
+ "file": "src/main/startup/desktop-startup-ordering.test.ts",
+ "assertions": [
+ "agent-browser cleanup is joined by the committed quit teardown barrier",
+ "repeatable serve signal handling is registered before readiness"
+ ]
+ },
+ {
+ "file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
+ "assertions": [
+ "serve supervisor force-kill grace covers renderer acknowledgement, committed teardown, and scheduling margin",
+ "Windows shared-console Ctrl-C is not forwarded as an immediate child termination"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-26",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/agent-browser-bridge-session-lifecycle.test.ts src/main/browser/agent-browser-bridge-tab-routing.test.ts src/main/startup/serve-signal-handlers.test.ts src/main/startup/desktop-startup-ordering.test.ts",
+ "durationSeconds": 0.44,
+ "summary": "Candidate passed 63 lifecycle, bridge, routing, signal, and quit-order tests plus 1,469 browser tests. The production-reverted control was red; disabling the final swap-timeout/admission controls failed 3 of 15 bridge lifecycle tests; removing the pending-retirement join, page-routing fence, repeat-signal behavior, or Windows shared-console guard failed its exact focused oracle. Setting the supervisor scheduling margin to zero reproduced SIGKILL at the combined 30-second renderer-acknowledgement and teardown boundary. Direct built-CLI serve observed exact PPID-1 helpers at 10-11 MiB RSS and 16-18 fd rows. Closing one page removed only its helper/session within five seconds while the other page survived reconnect. With the signal fix disabled, Ctrl-C left exact helpers alive after the listener exited; the final candidate emptied session inventory and removed app/helper PIDs and port within five seconds."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 2,
+ "scope": "bounded offscreen lifecycle contracts and bridge cleanup ordering"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "Fresh deterministic coverage has local candidate evidence only."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The pristine current-main control failed explicit close, unexpected destruction, backend shutdown, pending creation, bounded concurrency, joined quit, and repeated-signal assertions; the candidate passed the byte-identical oracle. Disabling only process-swap cleanup timeout and terminal command admission failed 3 of 15 bridge lifecycle tests. Live candidate-with-signal-fix-disabled also retained exact helpers after Ctrl-C, while the final candidate removed them."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Cleanup is event-driven and exact-page scoped: no polling, process-name scan, idle timer, or new wire field. Owner and residual session drains cap close subprocess concurrency at four, sequence headless ownership cleanup before the residual bridge sweep, and reuse the existing bounded app teardown deadline."
+ },
+ "promotionCriteria": [
+ "Run the same oracle on Linux headless serve with bundled agent-browser PID/session and RSS/fd inventory.",
+ "Exercise runtime restart, transport loss, reconnect, concurrent tabs, and paired/SSH host ownership without cross-page cleanup.",
+ "Collect repeated open/close/reconnect evidence showing bounded helper count and no stale PID-1-owned Orca identities."
+ ],
+ "knownGaps": [
+ "No 64 GiB Linux cgroup stress was manufactured; the reported production incident remains un-soaked.",
+ "The real Electron headless serve oracle ran on macOS; physical Linux/SSH/WSL and paired-client journeys remain unvalidated.",
+ "A close-command timeout or error is treated as best-effort and does not escalate to a process kill; helper disappearance on that failure path remains unverifiable without exact PID ownership.",
+ "Four-way close batches remain bounded but can exceed the 20-second app quit barrier above 16 worst-case five-second retirements.",
+ "The patch does not add startup stale-process recovery or an idle timeout; those require an exact persisted ownership ledger and are intentionally out of scope."
+ ],
+ "demotionRule": "Demote if a closed or destroyed page leaves its exact named helper session, if quit can exit before helper retirement settles, if unrelated live pages are retired, or if cleanup regresses to a global process-name kill or timer-only sweep."
+ },
+ {
+ "id": "terminal-session.remote-pane-layout-retry",
+ "title": "Remote pane layouts retry identical state after reconnect",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-layout-sync",
+ "layer": "renderer-paired-runtime-layout",
+ "surfaces": ["paired terminal split layout", "pane title", "runtime reconnect"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["paired-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["paired-runtime"],
+ "coverageNotes": "Pure contracts cover identity-equal local write bailouts and remote push deduplication, while a headless-host paired Electron journey proves that an identical local layout is retried after a failed disconnected push and becomes visible to an independent observer.",
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/12420"],
+ "invariant": "Identity-equal pane layout writes avoid local churn but never suppress a remote retry whose prior push failed; reconnecting and repeating the same layout persists it on the host and publishes it to other paired clients.",
+ "oracle": "Disconnect a paired client, change a pane title, and observe the failed host push while local state retains the layout. Reconnect without remounting, submit the identical title again, and require the headless host plus a second paired observer to receive it.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
+ "pnpm exec playwright test tests/e2e/paired-remote-pane-layout-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
+ "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
+ "tests/e2e/paired-remote-pane-layout-retry.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
+ "assertions": ["only successfully pushed layouts deduplicate later remote writes"]
+ },
+ {
+ "file": "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
+ "assertions": ["identity-equal layout and timer writes avoid redundant local updates"]
+ },
+ {
+ "file": "tests/e2e/paired-remote-pane-layout-retry.spec.ts",
+ "assertions": [
+ "an identical layout retries after reconnect and reaches a second paired observer"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-23",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
+ "durationSeconds": 5,
+ "summary": "Two focused files passed 20 remote-push and identity-bailout contracts."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 90,
+ "scope": "two focused renderer contracts plus one serial headless-host paired journey"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The existing paired sentinel is newly source-routed and has no route-specific CI soak history."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The affected implementation cached the disconnected layout before host acknowledgment and suppressed the identical retry; the merged oracle keeps only successful remote pushes deduplicated and the observer receives the retried title."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Successful identity-equal writes remain O(1) bailouts; one failed remote push permits one later identical retry, with no polling, timer, provider scan, subprocess, or new wire field."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
+ "Collect Linux and Windows paired-runtime reconnect evidence.",
+ "Keep local identity bailouts and failed-push retry ownership green."
+ ],
+ "knownGaps": [
+ "The paired reconnect journey has only local macOS evidence.",
+ "The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage."
+ ],
+ "demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause."
}
]
}
diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs
new file mode 100644
index 00000000000..efbafbe9a1b
--- /dev/null
+++ b/config/scripts/build-orcad-prebuilds.mjs
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+/**
+ * Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
+ * prebuilds matrix, so a deployment target needs no C/C++ toolchain.
+ *
+ * node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
+ * this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
+ * `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
+ * keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
+ * #9902. So the matrix is compiled from patched sources here, and this script refuses to
+ * run if the patch is not in the tree it is about to compile.
+ *
+ * orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
+ * only platform/arch/libc:
+ * linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
+ * darwin-x64, darwin-arm64
+ *
+ * CI runs this once per slot, each inside the container that owns that libc/arch, and
+ * merges the resulting `out/orcad/prebuilds` trees. `--slot=` forces the label so
+ * the glibc/musl distinction is recorded from the container rather than detected.
+ *
+ * Usage:
+ * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
+ * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
+ */
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import process from 'node:process'
+import { spawnSync } from 'node:child_process'
+
+const require = createRequire(import.meta.url)
+const ROOT = join(import.meta.dirname, '..', '..')
+const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
+
+/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
+export const MATRIX_SLOTS = [
+ 'linux-x64-glibc',
+ 'linux-arm64-glibc',
+ 'linux-x64-musl',
+ 'linux-arm64-musl',
+ 'darwin-x64',
+ 'darwin-arm64'
+]
+
+/**
+ * Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
+ * process is linked against glibc, and musl images have no `ldd` worth parsing.
+ */
+export function detectLibc(platform = process.platform, header = readReportHeader()) {
+ if (platform !== 'linux') {
+ return 'none'
+ }
+ return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
+}
+
+function readReportHeader() {
+ try {
+ return process.report?.getReport?.()?.header
+ } catch {
+ return undefined
+ }
+}
+
+export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
+ const forced = argv.find((arg) => arg.startsWith('--slot='))
+ if (forced) {
+ return forced.slice('--slot='.length)
+ }
+ const libc = detectLibc(platform)
+ return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
+}
+
+/**
+ * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
+ * that loads fine on the build host and dies on the target — the exact failure the matrix
+ * exists to prevent, now baked into a shipped artifact instead of a first-connect error.
+ */
+export function assertNodePtyPatchApplied(nodePtyDir) {
+ const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
+ const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
+ const missing = []
+ if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
+ missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
+ }
+ if (!ptySource.includes('.symver openpty,openpty@')) {
+ missing.push('src/unix/pty.cc is missing the .symver glibc pins')
+ }
+ if (missing.length > 0) {
+ throw new Error(
+ [
+ '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
+ ...missing.map((line) => ` - ${line}`),
+ 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
+ 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
+ ].join('\n')
+ )
+ }
+}
+
+export function readManifest(prebuildsDir) {
+ try {
+ return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Why merge rather than overwrite: CI builds one slot per container and merges the trees.
+ * A manifest that records only the last slot would erase every other container's record,
+ * and `--require-slots` would then reject a complete matrix.
+ */
+export function mergeManifest(existing, next) {
+ const slots = new Set([...(existing?.slots ?? []), next.slot])
+ return {
+ module: 'node-pty',
+ version: next.version,
+ nodeAbi: next.nodeAbi,
+ slots: [...slots].sort()
+ }
+}
+
+function nodePtyDir() {
+ return dirname(require.resolve('node-pty/package.json'))
+}
+
+function compileNodePty(dir) {
+ const built = join(dir, 'build', 'Release', 'pty.node')
+ if (existsSync(built)) {
+ console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
+ return built
+ }
+ console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
+ const result = spawnSync(
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
+ ['node-gyp', 'rebuild'],
+ {
+ cwd: dir,
+ stdio: 'inherit',
+ env: process.env,
+ windowsHide: true
+ }
+ )
+ if (result.status !== 0) {
+ throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
+ }
+ if (!existsSync(built)) {
+ throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
+ }
+ return built
+}
+
+function requireSlots() {
+ const manifest = readManifest(PREBUILDS_DIR)
+ const have = new Set(manifest?.slots ?? [])
+ const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
+ if (missing.length > 0) {
+ console.error(
+ `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
+ 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
+ )
+ process.exitCode = 1
+ return
+ }
+ console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
+}
+
+function build() {
+ const dir = nodePtyDir()
+ assertNodePtyPatchApplied(dir)
+ const slot = slotName()
+ const slotDir = join(PREBUILDS_DIR, slot)
+ mkdirSync(slotDir, { recursive: true })
+
+ const builtBinary = compileNodePty(dir)
+ copyFileSync(builtBinary, join(slotDir, 'pty.node'))
+ console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
+
+ // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
+ // so a slot without it installs cleanly and then fails ENOENT the first time a user
+ // opens a terminal. Windows has no spawn-helper.
+ if (process.platform !== 'win32') {
+ const helperSource = join(dirname(builtBinary), 'spawn-helper')
+ if (!existsSync(helperSource)) {
+ throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
+ }
+ copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
+ console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
+ }
+
+ // The static floor gate, applied to the artifact we are about to ship rather than only
+ // to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
+ if (process.platform === 'linux') {
+ const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
+ verifyLinuxGlibcFloor(slotDir)
+ }
+
+ const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
+ slot,
+ version: require('node-pty/package.json').version,
+ nodeAbi: process.versions.modules
+ })
+ writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
+ console.log(
+ `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
+ )
+}
+
+if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
+ if (process.argv.includes('--require-slots')) {
+ requireSlots()
+ } else {
+ build()
+ }
+}
diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs
new file mode 100644
index 00000000000..c91c7e6cc9d
--- /dev/null
+++ b/config/scripts/build-orcad-prebuilds.test.mjs
@@ -0,0 +1,123 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ assertNodePtyPatchApplied,
+ detectLibc,
+ MATRIX_SLOTS,
+ mergeManifest,
+ readManifest,
+ slotName
+} from './build-orcad-prebuilds.mjs'
+
+const PATCHED_BINDING_GYP =
+ "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
+const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
+
+const dirs = []
+const stage = (bindingGyp, ptyCc) => {
+ const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
+ dirs.push(dir)
+ mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
+ writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
+ writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
+ return dir
+}
+afterEach(() => {
+ for (const dir of dirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+describe('assertNodePtyPatchApplied', () => {
+ it('accepts a tree with both halves of the glibc-floor fix', () => {
+ expect(() =>
+ assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
+ ).not.toThrow()
+ })
+
+ it('refuses to build when the ldflags half is missing', () => {
+ // The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
+ // DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
+ // this time baked into a shipped prebuilt.
+ expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
+ /--no-as-needed,-l:libutil\.so\.1/
+ )
+ })
+
+ it('refuses to build when the .symver pins are missing', () => {
+ expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
+ /\.symver glibc pins/
+ )
+ })
+
+ it('names the patch and the doc so the fix is findable', () => {
+ expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
+ /config\/patches\/node-pty@1\.1\.0\.patch/
+ )
+ })
+})
+
+describe('slot naming', () => {
+ it('covers every platform orcad ships to', () => {
+ expect([...MATRIX_SLOTS].sort()).toEqual([
+ 'darwin-arm64',
+ 'darwin-x64',
+ 'linux-arm64-glibc',
+ 'linux-arm64-musl',
+ 'linux-x64-glibc',
+ 'linux-x64-musl'
+ ])
+ })
+
+ it('lets CI force the label so the container decides glibc vs musl', () => {
+ // Detection inside a container that happens to run a differently-linked Node would
+ // file the build under the wrong slot. The forced label must beat detection outright,
+ // so assert against one detection could never produce for this platform/arch.
+ expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
+ 'linux-x64-glibc'
+ )
+ })
+
+ it('omits the libc dimension off Linux', () => {
+ expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
+ })
+
+ it('reads glibc from the report header and musl from its absence', () => {
+ expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
+ expect(detectLibc('linux', {})).toBe('musl')
+ expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
+ })
+})
+
+describe('mergeManifest', () => {
+ it('accumulates slots across the per-container CI runs that build them', () => {
+ // Overwriting would erase every other container's record, and the release gate would
+ // then reject a matrix that is actually complete.
+ const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
+ const second = mergeManifest(first, {
+ slot: 'linux-arm64-musl',
+ version: '1.1.0',
+ nodeAbi: '127'
+ })
+
+ expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
+ expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
+ })
+
+ it('does not duplicate a slot rebuilt twice', () => {
+ const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
+ expect(
+ mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
+ ).toEqual(['darwin-arm64'])
+ })
+})
+
+describe('readManifest', () => {
+ it('returns null instead of throwing when no matrix has been built', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
+ dirs.push(dir)
+ expect(readManifest(dir)).toBeNull()
+ })
+})
diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs
index 989efd2d8e7..69c12f0ecc9 100644
--- a/config/scripts/build-orcad.mjs
+++ b/config/scripts/build-orcad.mjs
@@ -7,14 +7,45 @@
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
+import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
-import { mkdirSync, rmSync } from 'node:fs'
+import { createHash } from 'node:crypto'
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs'
+import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
+import {
+ ORCAD_VERSION,
+ ORCAD_VERSION_FILENAME,
+ orcadArtifactFilenames
+} from '../../src/shared/orcad-artifacts.ts'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
+// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
+// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
+// looks for it in the app root. A deployment has no desktop out/main to fall back to.
+const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
+const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
+// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
+// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
+// orcad restart would SIGKILL every running terminal.
+const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
+const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
+const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
+const OUT_FILE = join(OUT_DIR, 'orcad.js')
+const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
+const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
@@ -48,6 +79,36 @@ const externalNativeAddons = {
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
+copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
+if (process.platform !== 'win32') {
+ chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
+}
+
+/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
+ * directory under `outdir`, and both children must land flat beside orcad.js — that is where
+ * their runtime resolvers look for them. */
+function buildForkedChild(entryPoint, outfile) {
+ return build({
+ entryPoints: [entryPoint],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ outfile,
+ external: EXTERNAL,
+ plugins: [externalNativeAddons],
+ metafile: true,
+ minify: true,
+ sourcemap: false,
+ define: { 'process.env.NODE_ENV': '"production"' },
+ logLevel: 'error'
+ })
+}
+
+const childResults = await Promise.all([
+ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
+ buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
+])
const result = await build({
entryPoints: [ENTRY],
@@ -55,7 +116,7 @@ const result = await build({
platform: 'node',
target: 'node18',
format: 'cjs',
- outfile: join(OUT_DIR, 'orcad.js'),
+ outfile: OUT_FILE,
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
@@ -65,32 +126,184 @@ const result = await build({
logLevel: 'error'
})
-const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
+const output = Object.values(result.metafile.outputs).find(
+ (o) => o.entryPoint === 'src/main/orcad/main.ts'
+)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
-const electronImporters = new Set()
-for (const [file, info] of Object.entries(result.metafile.inputs)) {
- for (const imported of info.imports ?? []) {
- const specifier = imported.original ?? imported.path
- if (specifier === 'electron' || specifier.startsWith('electron/')) {
- electronImporters.add(file)
+// Why both metafiles: the forked children ship in the same deployment and run under the
+// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
+// path whose whole point is that terminals survive.
+function collectImporters(metafiles, matches) {
+ const importers = new Set()
+ for (const metafile of metafiles) {
+ for (const [file, info] of Object.entries(metafile.inputs)) {
+ for (const imported of info.imports ?? []) {
+ if (matches(imported.original ?? imported.path)) {
+ importers.add(file)
+ }
+ }
}
}
+ return importers
}
+const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
+const electronImporters = collectImporters(
+ metafiles,
+ (specifier) => specifier === 'electron' || specifier.startsWith('electron/')
+)
+const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
+
+const graphErrors = []
if (electronImporters.size > 0) {
- console.error(
- `[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
-${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
+ graphErrors.push(
+ `${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
+ .map((file) => ` - ${file}`)
+ .join('\n')}`
)
+}
+if (sqliteImporters.size > 0) {
+ graphErrors.push(
+ `${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
+ .map((file) => ` - ${file}`)
+ .join('\n')}`
+ )
+}
+
+if (graphErrors.length > 0) {
+ console.error(`[build-orcad] ${graphErrors.join('\n')}`)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
// point so the two numbers cannot drift.
process.exitCode = 1
} else {
+ // Why smoke-load and not just read the metafile: the import scan proves no module
+ // *names* electron, but a graph can still fail to resolve under plain Node — a
+ // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
+ // smoke-loads its entries for exactly this reason, and orcad cannot join that guard
+ // because it is an esbuild artifact rather than a rollup input.
+ // Why an exit code and not a message match: these bundles are minified onto one line, so
+ // Node's uncaught-exception report echoes that whole line — which contains every string
+ // literal in the bundle. A crash therefore "matches" any expected message, and a textual
+ // assertion passes against a bundle that never loaded.
+ const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
+ encoding: 'utf8',
+ timeout: 60_000
+ })
+ const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
+ if (smoke.error || smoke.signal || smoke.status !== 0) {
+ console.error(
+ `[build-orcad] the bundle did not load under plain Node.\n` +
+ `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
+ `signal=${smoke.signal ?? 'none'} ` +
+ `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
+ )
+ process.exitCode = 1
+ }
+ // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
+ // top-level import, and calling its exported argv parser proves the entry's own code is
+ // there rather than a graph that merely resolved. Booting one would need a socket, a
+ // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
+ // The verdict is carried by the exit code for the same minification reason as above.
+ const daemonSmoke = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
+ `if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
+ `try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
+ ],
+ {
+ encoding: 'utf8',
+ timeout: 60_000,
+ env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
+ }
+ )
+ const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
+ if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
+ console.error(
+ `[build-orcad] the daemon child did not load under plain Node.\n` +
+ `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
+ `signal=${daemonSmoke.signal ?? 'none'} ` +
+ `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
+ )
+ process.exitCode = 1
+ }
+ const watcherFailure = await smokeLoadWatcherChild()
+ if (watcherFailure) {
+ console.error(
+ `[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
+ )
+ process.exitCode = 1
+ }
+}
+
+// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
+// this string, so two different builds carrying one version would share a directory — and an
+// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
+// bytes while reporting the new version.
+if (process.exitCode !== 1) {
+ const hash = createHash('sha256')
+ for (const filename of orcadArtifactFilenames()) {
+ const artifactPath = join(OUT_DIR, filename)
+ if (!existsSync(artifactPath)) {
+ throw new Error(
+ `orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` +
+ 'step, or drop it from src/shared/orcad-artifacts.ts.'
+ )
+ }
+ hash.update(readFileSync(artifactPath))
+ }
+ const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
+ writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
- `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
+ `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
)
}
+
+/**
+ * Fork the shipped watcher child and drive one message through it.
+ *
+ * Why a real fork and not existsSync: the file being present says nothing about whether
+ * its graph resolves under plain Node, and this child is only ever reached through
+ * `fork()` at runtime — a broken one degrades silently to in-process watching.
+ * `subscribe-started` is acked before the native module is touched, so this passes on a
+ * build machine with no compiled @parcel/watcher.
+ */
+async function smokeLoadWatcherChild() {
+ const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
+ const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
+ let stderr = ''
+ child.stderr?.on('data', (chunk) => {
+ stderr += String(chunk)
+ })
+ try {
+ return await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL')
+ resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
+ }, 30_000)
+ const settle = (failure) => {
+ clearTimeout(timer)
+ resolve(failure)
+ }
+ child.on('message', (message) => {
+ if (message?.op === 'subscribe-started') {
+ child.disconnect()
+ }
+ })
+ child.on('error', (error) => settle(`fork failed: ${error.message}`))
+ // Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
+ // or a signal here is a load failure rather than a clean teardown.
+ child.on('exit', (code, signal) =>
+ settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
+ )
+ child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
+ })
+ } finally {
+ rmSync(probeDir, { recursive: true, force: true })
+ }
+}
diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs
index 125538a7236..506036ede3a 100644
--- a/config/scripts/build-relay.mjs
+++ b/config/scripts/build-relay.mjs
@@ -23,6 +23,8 @@ import { join } from 'node:path'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
+ RELAY_WINDOWS_PROCESS_TREE_FILENAME,
+ relayOptionalArtifactFilenames,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
@@ -55,6 +57,38 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
+// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
+// Windows machine.
+const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
+
+// Which Windows arches must have the addon, as a comma-separated list ('all' for
+// every arch). Per-arch rather than a flag because arm64 needs the MSVC ARM64
+// cross toolset, an optional VS component: where it is absent that relay should
+// fall back to the scan, not fail the release the x64 relay is riding on.
+const REQUIRED_ADDON_ARCHES = (process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS ?? '')
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean)
+
+function stageWindowsProcessTreeAddon(platform, outDir) {
+ if (!isWindowsRelayPlatform(platform)) {
+ return
+ }
+ const arch = platform.slice('win32-'.length)
+ const source = join(WINDOWS_PROCESS_TREE_BUILD_DIR, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
+ if (!existsSync(source)) {
+ if (REQUIRED_ADDON_ARCHES.includes(arch) || REQUIRED_ADDON_ARCHES.includes('all')) {
+ throw new Error(
+ `Relay ${platform} needs ${source}. Run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch} (Windows only).`
+ )
+ }
+ console.log(
+ `Relay ${platform}: no ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}; relay will use the PowerShell scan.`
+ )
+ return
+ }
+ copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME))
+}
// Why: lets the packaging contract test build into a temp tree instead of
// clobbering a developer's out/relay or racing tests that read it.
@@ -92,6 +126,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
+ stageWindowsProcessTreeAddon(platform, outDir)
await build({
entryPoints: [WATCHER_ENTRY],
@@ -172,12 +207,25 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
}
hash.update(readFileSync(artifactPath))
}
+ // Why hashed only when present: a relay carrying the native addon answers
+ // differently from one that falls back to the scan, so the two must not share
+ // an immutable directory -- but a build without it is still valid.
+ for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) {
+ const artifactPath = join(outDir, filename)
+ if (existsSync(artifactPath)) {
+ hash.update(readFileSync(artifactPath))
+ }
+ }
const contentHash = hash.digest('hex').slice(0, 12)
// Close the loop: an artifact emitted here but absent from the manifest would
// ship unhashed and unprobed — exactly how the WSL helper went missing.
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
- const undeclared = emitted.filter((name) => !expected.includes(name))
+ const declared = [
+ ...expected,
+ ...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))
+ ]
+ const undeclared = emitted.filter((name) => !declared.includes(name))
if (undeclared.length > 0) {
throw new Error(
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
@@ -194,11 +242,12 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
- const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
+ const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
+ const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts')
const outDir = join(OUT_ROOT, 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
- entryPoints: [wslEntry],
+ entryPoints: [wslHookEntry],
bundle: true,
platform: 'node',
target: 'node18',
@@ -214,6 +263,27 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
+
+ await build({
+ entryPoints: [wslBrowserNetworkEntry],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ outfile: join(outDir, 'wsl-browser-network-relay.js'),
+ sourcemap: false,
+ minify: true,
+ define: {
+ 'process.env.NODE_ENV': '"production"'
+ }
+ })
+ const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js'))
+ const browserNetworkHash = createHash('sha256')
+ .update(browserNetworkContent)
+ .digest('hex')
+ .slice(0, 12)
+ writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`)
+ console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`)
}
console.log('Relay build complete.')
diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs
index 27c17b3d15c..9d3c9020706 100644
--- a/config/scripts/build-windows-cli-launcher.mjs
+++ b/config/scripts/build-windows-cli-launcher.mjs
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
-import { existsSync, mkdirSync } from 'node:fs'
+import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
-if (process.platform !== 'win32') {
- // Why: electron-builder treats a skipped native build like success and can
- // continue toward a Windows package whose declared orca.exe does not exist.
- throw new Error(
- 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
- )
-}
-
-const repoRoot = resolve(import.meta.dirname, '../..')
-const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
-const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
-const compilerPath = findFrameworkCompiler(process.env)
-
-if (!compilerPath) {
- throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
-}
-
-mkdirSync(dirname(outputPath), { recursive: true })
-const result = spawnSync(
- compilerPath,
- ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
- { cwd: repoRoot, stdio: 'inherit' }
-)
-
-if (result.signal) {
- process.kill(process.pid, result.signal)
-}
-if (result.error) {
- throw result.error
-}
-if (result.status !== 0) {
- process.exit(result.status ?? 1)
+export function shouldReuseCompiledWindowsCliLauncher(
+ outputPath,
+ sourcePath,
+ { reuseCached = false } = {}
+) {
+ if (!existsSync(outputPath)) {
+ return false
+ }
+ // Why reuseCached: Actions cache keys already hash the C# source, but restore
+ // does not preserve mtimes, so a hit would look stale and recompile anyway.
+ if (reuseCached) {
+ return true
+ }
+ return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ if (process.platform !== 'win32') {
+ // Why: electron-builder treats a skipped native build like success and can
+ // continue toward a Windows package whose declared orca.exe does not exist.
+ throw new Error(
+ 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
+ )
+ }
+
+ const repoRoot = resolve(import.meta.dirname, '../..')
+ const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
+ const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
+ const compilerPath = findFrameworkCompiler(process.env)
+
+ if (!compilerPath) {
+ throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
+ }
+
+ mkdirSync(dirname(outputPath), { recursive: true })
+ if (
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
+ reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
+ })
+ ) {
+ console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
+ process.exit(0)
+ }
+ 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)
+ }
+}
diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs
index 4bb219a5687..82332897715 100644
--- a/config/scripts/build-windows-cli-launcher.test.mjs
+++ b/config/scripts/build-windows-cli-launcher.test.mjs
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
+ statSync,
+ utimesSync,
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'
+import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
+ it('reuses a compiled launcher that is at least as new as the C# source', () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
+ try {
+ const sourcePath = join(root, 'OrcaCliLauncher.cs')
+ const outputPath = join(root, '.build', 'orca.exe')
+ mkdirSync(join(root, '.build'))
+ writeFileSync(sourcePath, 'source\n')
+ writeFileSync(outputPath, 'binary\n')
+ const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
+ utimesSync(outputPath, later, later)
+
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
+ writeFileSync(sourcePath, 'changed\n')
+ const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
+ utimesSync(sourcePath, sourceLater, sourceLater)
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
+ expect(
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
+ ).toBe(true)
+ expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
+ false
+ )
+ } finally {
+ removeFixtureTree(root)
+ }
+ })
+
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs
new file mode 100644
index 00000000000..3e43beb359e
--- /dev/null
+++ b/config/scripts/build-windows-process-tree-relay-addon.mjs
@@ -0,0 +1,202 @@
+#!/usr/bin/env node
+/**
+ * Compile `@vscode/windows-process-tree` for a relay host.
+ *
+ * The relay is deployed to machines with no compiler, and this addon cannot be
+ * npm-installed there: it carries a binding.gyp, so npm rebuilds from source and
+ * the build wants Spectre-mitigated libraries even where MSVC is present. The
+ * binary inside the published tarball loads, but predates our patch and still
+ * caps enumeration at 1024 processes -- a busy host then gets a truncated table
+ * missing its own pid, which reads as "unavailable" only under load.
+ *
+ * So we compile it here, from the patched source pnpm already materialized, and
+ * ship the result as a relay artifact. Windows arm64 cross-compiles from an x64
+ * runner, so both arches come off one Windows job.
+ *
+ * Node headers, not Electron: the relay runs under the host's own `node`. The
+ * addon is N-API, so one build serves every Node the remote might have.
+ *
+ * node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
+ */
+import { execFileSync } from 'node:child_process'
+import {
+ closeSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ readSync,
+ writeFileSync
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
+
+const ROOT = resolve(import.meta.dirname, '..', '..')
+const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree')
+const SUPPORTED_ARCHES = ['x64', 'arm64']
+
+/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
+const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
+
+function parseArgs(argv) {
+ const arch = argv.find((a) => a.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch
+ const outDir = argv.find((a) => a.startsWith('--out='))?.slice('--out='.length)
+ if (!SUPPORTED_ARCHES.includes(arch)) {
+ throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`)
+ }
+ return {
+ arch,
+ outDir: outDir ? resolve(outDir) : join(ROOT, '.build', 'windows-process-tree', arch)
+ }
+}
+
+/**
+ * Refuse to build unpatched source.
+ *
+ * Each hunk fails differently: Spectre dies outright, the 1024-process cap
+ * succeeds and lies, and `.targets` is cwd-relative so pnpm's nested layout
+ * makes node-gyp miss node_addon_api.gyp on Windows. Checking the source
+ * rather than trusting the install is what stops a silently unpatched tree
+ * from being shipped as if it were patched.
+ */
+function assertPatchApplied() {
+ const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
+ if (bindingGyp.includes('SpectreMitigation')) {
+ throw new Error(
+ 'binding.gyp still requests SpectreMitigation. pnpm did not apply ' +
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
+ )
+ }
+ if (bindingGyp.includes('node_addon_api.gyp')) {
+ throw new Error(
+ 'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
+ 'project path incorrectly on Windows. ' +
+ 'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
+ )
+ }
+ if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
+ throw new Error('binding.gyp does not use the staged node-addon-api headers.')
+ }
+ const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
+ if (processCc.includes('process_count < 1024')) {
+ throw new Error(
+ 'src/process.cc still caps enumeration at 1024 processes. pnpm did not apply ' +
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
+ )
+ }
+}
+
+// pnpm can materialize this CRLF package without applying its patch. Repair the
+// load-bearing build settings before node-gyp so the release build stays safe.
+function applyWindowsProcessTreeBuildFixes() {
+ const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
+ const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
+ const nodeAddonApiDir = dirname(
+ createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
+ )
+ const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
+ let bindingGyp = readFileSync(bindingPath, 'utf8')
+ let processCc = readFileSync(processPath, 'utf8')
+ const originalBinding = bindingGyp
+ const originalProcess = processCc
+
+ for (const dynamicDependency of [
+ String.raw` ${staged}`)
+}
+
+try {
+ main()
+} catch (error) {
+ console.error(`[windows-process-tree] ${error instanceof Error ? error.message : String(error)}`)
+ process.exit(1)
+}
diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs
index 2a3542e78b9..615a2402bcf 100644
--- a/config/scripts/check-changed-code-quality.mjs
+++ b/config/scripts/check-changed-code-quality.mjs
@@ -139,7 +139,99 @@ function diagnosticLineRange(root, filename, span) {
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
}
-export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
+// Why: a file-splitting refactor makes every line of the new module an "added"
+// line, so pre-existing lint debt in code that merely MOVED starts failing the
+// changed-lines gate. The only way to satisfy it is to edit the moved code,
+// which is exactly what a behavior-preserving refactor must not do. So a
+// diagnostic is exempt when its highlighted lines already existed, verbatim and
+// contiguous, somewhere in the base revision of the files this change touches.
+function normalizeSourceLine(line) {
+ return line.replace(/\s+/g, ' ').trim()
+}
+
+export function collectBaseLineBlocks(root, comparisonBase, files = null) {
+ // Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
+ // often deleted or renamed away. Deleted paths never reach the changed-file list
+ // (it filters to ACMRTUB), so read every path the diff touches, deletions included.
+ const paths =
+ files ??
+ splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter(
+ (file) => SOURCE_FILE_PATTERN.test(file)
+ )
+ const blocks = []
+ for (const file of paths) {
+ const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
+ cwd: root,
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024
+ })
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
+ continue
+ }
+ blocks.push(
+ result.stdout
+ .split(/\r?\n/)
+ .map(normalizeSourceLine)
+ .filter((line) => line !== '')
+ )
+ }
+ return blocks
+}
+
+export function isMovedCode(highlightedLines, baseBlocks) {
+ const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
+ if (needle.length === 0) {
+ return false
+ }
+ // Why a near-match rather than an exact contiguous one: a split moves a block
+ // verbatim but a diagnostic's span often reaches past it — most commonly to a
+ // hook dependency array, which legitimately grows when closure variables become
+ // props. Requiring every line to match would report the moved body as new. So:
+ // the block must still start at the same line in the base and appear IN ORDER,
+ // and nearly all of it must be present. Genuinely new code shares neither the
+ // anchor nor the ordering, so it stays reported.
+ const MIN_COVERAGE = 0.9
+ return baseBlocks.some((rawHaystack) => {
+ const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
+ for (let start = 0; start < haystack.length; start += 1) {
+ if (haystack[start] !== needle[0]) {
+ continue
+ }
+ let matched = 1
+ let cursor = start + 1
+ for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
+ while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
+ cursor += 1
+ }
+ if (cursor < haystack.length) {
+ matched += 1
+ cursor += 1
+ }
+ }
+ if (matched / needle.length >= MIN_COVERAGE) {
+ return true
+ }
+ }
+ return false
+ })
+}
+
+function diagnosticHighlightedLines(root, filename, span) {
+ const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
+ const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
+ const range = diagnosticLineRange(root, filename, span)
+ if (range === null) {
+ return []
+ }
+ return source.slice(range.start - 1, range.end)
+}
+
+export function diagnosticTouchesAddedLines(
+ diagnostic,
+ rangesByFile,
+ root = process.cwd(),
+ baseBlocks = []
+) {
const file = normalizedDiagnosticPath(root, diagnostic.filename)
const ranges = rangesByFile.get(file)
if (!ranges) {
@@ -147,7 +239,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
}
return (diagnostic.labels ?? []).some((label) => {
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
- return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
+ if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
+ return false
+ }
+ return !isMovedCode(
+ diagnosticHighlightedLines(root, diagnostic.filename, label.span),
+ baseBlocks
+ )
})
}
@@ -193,10 +291,12 @@ export function main(
return 0
}
+ const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
+
let failures = 0
for (const scan of OXLINT_SCANS) {
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
- diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
+ diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
)
for (const diagnostic of diagnostics) {
printDiagnostic(diagnostic, root)
diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs
index 5daca8da49e..76a25802e5c 100644
--- a/config/scripts/check-changed-code-quality.test.mjs
+++ b/config/scripts/check-changed-code-quality.test.mjs
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
OXLINT_SCANS,
diagnosticTouchesAddedLines,
+ isMovedCode,
overlapsAddedLines,
parseAddedLineRanges
} from './check-changed-code-quality.mjs'
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
expect(scan.args).not.toContain('--disable-nested-config')
})
})
+
+describe('moved-code exemption', () => {
+ it('treats a verbatim contiguous block from the base as moved', () => {
+ const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
+ expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
+ })
+
+ it('ignores indentation and whitespace changes from the move', () => {
+ const base = [[' items.map((item, index) => (', ' key={index}']]
+ expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
+ })
+
+ it('does not exempt a genuinely new violation', () => {
+ const base = [['const a = 1', 'const b = 2']]
+ expect(isMovedCode(['rows.map((row, i) =>
)'], base)).toBe(false)
+ })
+
+ it('does not exempt a block that is only partly present in the base', () => {
+ const base = [['doThing()', 'unrelated()']]
+ expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
+ })
+
+ it('tolerates a few lines appended inside the moved block', () => {
+ // A split commonly grows a hook dependency array when closure variables
+ // become props; the moved body around it is still moved.
+ const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
+ const base = [body]
+ const moved = [...body.slice(0, 19), 'newDep,', body[19]]
+ expect(isMovedCode(moved, base)).toBe(true)
+ })
+
+ it('does not exempt when the anchor line is absent from the base', () => {
+ const base = [['doThing()', 'filler()', 'other()']]
+ expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
+ })
+
+ it('does not exempt when most of the block is absent from the base', () => {
+ const base = [['keep0()', 'keep1()', 'unrelated()']]
+ const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
+ expect(isMovedCode(mostlyNew, base)).toBe(false)
+ })
+
+ it('ignores blank lines when matching', () => {
+ const base = [['a()', 'b()']]
+ expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
+ })
+
+ it('never exempts an empty highlight', () => {
+ expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
+ })
+})
diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs
index c50d5df8f46..7c165bfc02b 100644
--- a/config/scripts/check-runtime-electron-ratchet.mjs
+++ b/config/scripts/check-runtime-electron-ratchet.mjs
@@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
// itself and the RPC server that fronts it.
const ENTRY_POINTS = [
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
- path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
+ path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
+ // Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
+ // graph is strictly larger than the two runtime entries. Measuring only those let the
+ // two numbers drift — the gate would read zero while the shipped artifact regressed.
+ path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
]
// Native addons and electron cannot be bundled; externalising them is what the
diff --git a/config/scripts/client-hosted-browser-package-coverage.test.mjs b/config/scripts/client-hosted-browser-package-coverage.test.mjs
new file mode 100644
index 00000000000..3f4b7b51f87
--- /dev/null
+++ b/config/scripts/client-hosted-browser-package-coverage.test.mjs
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+
+const projectDir = resolve(import.meta.dirname, '../..')
+
+describe('client-hosted browser package coverage', () => {
+ it('bundles the WSL browser-network relay with its version stamp', () => {
+ const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
+
+ expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')")
+ expect(relayBuild).toContain("join(outDir, '.browser-network-version')")
+ })
+
+ it('runs client-hosted Electron lifecycle coverage on native package hosts', () => {
+ const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
+ const parsedWorkflow = parse(prWorkflow)
+ const linuxStep = parsedWorkflow.jobs.package.steps.find(
+ (step) => step.name === 'Test Linux Electron lifecycle boundary'
+ )
+ const windowsStep = parsedWorkflow.jobs.package_windows.steps.find(
+ (step) => step.name === 'Test Windows-specific boundaries'
+ )
+
+ const required = [
+ 'browser-client-page-renderer-lifecycle',
+ 'browser-route-webrtc-egress',
+ 'browser-route-tcp-egress',
+ 'browser-route-h3-egress',
+ 'browser-route-dns-prefetch'
+ ].map((name) => `src/main/browser/${name}.electron.test.ts`)
+
+ expect(linuxStep.run).toContain('xvfb-run --auto-servernum')
+ for (const file of required) {
+ expect(linuxStep.run).toContain(file)
+ expect(windowsStep.run).toContain(file)
+ }
+ })
+})
diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs
index 11eae65d996..d0489db64cd 100644
--- a/config/scripts/computer-e2e-workflow.test.mjs
+++ b/config/scripts/computer-e2e-workflow.test.mjs
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
- 'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
+ expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs
index f43fd846263..d9796f3a962 100644
--- a/config/scripts/daemon-boot-smoke.mjs
+++ b/config/scripts/daemon-boot-smoke.mjs
@@ -238,7 +238,11 @@ async function main() {
throw new Error('daemon readiness did not publish the expected PID ownership record')
}
log('PID ownership record matches the ready daemon')
- if (!existsSync(socketPath)) {
+ const endpointPublished =
+ process.platform === 'win32'
+ ? (await probeEndpoint(socketPath)) === 'connected'
+ : existsSync(socketPath)
+ if (!endpointPublished) {
throw new Error('daemon did not publish its endpoint at the canonical socket path')
}
log('endpoint published at the canonical socket path')
diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts
index 6d30d70174b..0937fbf0860 100644
--- a/config/scripts/electron-vite-output-contract.test.ts
+++ b/config/scripts/electron-vite-output-contract.test.ts
@@ -102,6 +102,10 @@ describe('Electron Vite output contract', () => {
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
+ it('bundles validation dependencies used by the sandboxed preload', () => {
+ expect(electronViteConfig.preload?.build?.externalizeDeps?.exclude).toContain('zod')
+ })
+
it('exits when a static import fails before source error guards load', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
diff --git a/config/scripts/ensure-native-runtime-job-ownership.test.mjs b/config/scripts/ensure-native-runtime-job-ownership.test.mjs
new file mode 100644
index 00000000000..91b4c5d328d
--- /dev/null
+++ b/config/scripts/ensure-native-runtime-job-ownership.test.mjs
@@ -0,0 +1,67 @@
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
+const NODE_PTY_PATCH = readFileSync(
+ new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
+ 'utf8'
+)
+
+const PATCHED = {
+ dir: 'build/Release/',
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+}
+const PREBUILD = {
+ dir: 'prebuilds/win32-x64/',
+ module: {
+ startProcess: () => {},
+ connect: () => {},
+ resize: () => {},
+ clear: () => {},
+ kill: () => {}
+ }
+}
+
+describe('assertNodePtyJobOwnership', () => {
+ it('keeps node-addon-api project paths absolute during Windows source builds', () => {
+ expect(NODE_PTY_PATCH).toContain(
+ `+ " {
+ expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'")
+ expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
+ })
+
+ it('rejects the prebuild that shipped without the job exports', () => {
+ expect(() =>
+ assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
+ ).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
+ })
+
+ it('names where the bad native came from, so the fix is obvious', () => {
+ expect(() =>
+ assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
+ ).toThrow(/prebuilds\/win32-x64/)
+ })
+
+ it('accepts a source build carrying the patch', () => {
+ expect(() =>
+ assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
+ ).not.toThrow()
+ })
+
+ it.each([
+ ['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
+ ['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
+ ])('stays out of the way on %s', (_case, spec) => {
+ expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
+ })
+})
diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs
index 941049472fb..424b671b11e 100644
--- a/config/scripts/ensure-native-runtime.mjs
+++ b/config/scripts/ensure-native-runtime.mjs
@@ -4,9 +4,10 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
-import { basename, resolve } from 'node:path'
+import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
+const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
@@ -66,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
- runPnpm(['rebuild', 'node-pty'])
+ const failedModules = initial.failures.map((failure) => failure.moduleName)
+ const rebuildModules = [
+ 'node-pty',
+ ...failedModules.filter((moduleName) => moduleName !== 'node-pty')
+ ]
+ rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -76,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
- runPnpm(['rebuild', ...failedModules])
+ rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -277,6 +283,7 @@ function loadNodePtyNativeModule() {
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
const native = loadNativeModule(nativeName)
assertNodePtyWindowsConptyRuntime(native?.dir)
+ assertNodePtyJobOwnership({ nativeName, native })
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
throw new Error(
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
@@ -310,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
- // patch only lands in the source-built build/Release artifacts.
+ // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
+ // Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -327,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
-function requiresPatchedNodePtySourceBuild() {
+function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
- return false
+ const releaseDir = resolve(nodePtyDir, 'build', 'Release')
+ return [
+ resolve(releaseDir, 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
+ ]
}
+ const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
+ // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
+ if (process.platform === 'darwin') {
+ artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
+ }
+ return artifactPaths
+}
+
+function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -349,16 +365,32 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
-function runPnpm(args) {
+function rebuildNodeRuntimeModules(moduleNames) {
+ for (const moduleName of moduleNames) {
+ const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
+ console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
+ runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
+ if (moduleName === 'node-pty' && process.platform === 'win32') {
+ runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
+ }
+ }
+}
+
+function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+ const env =
+ process.platform === 'linux' && args.includes('node-gyp')
+ ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
+ : process.env
const result = spawnSync(command, args, {
- cwd: projectDir,
+ cwd,
stdio: 'inherit',
- shell: process.platform === 'win32'
+ shell: process.platform === 'win32',
+ env
})
if (result.error || result.status !== 0) {
- console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
+ console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs
index 0bc8e5ae077..ea6e876e619 100644
--- a/config/scripts/ensure-native-runtime.test.mjs
+++ b/config/scripts/ensure-native-runtime.test.mjs
@@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
+const sourceNodePtyJobOwnershipPath = fileURLToPath(
+ new URL('./node-pty-job-ownership.cjs', import.meta.url)
+)
describe('ensure-native-runtime', () => {
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
@@ -26,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
+ writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -39,7 +43,11 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
- expect(log).toContain('pnpm rebuild node-pty\n')
+ expect(log).toContain('pnpm exec node-gyp rebuild\n')
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ if (process.platform === 'linux') {
+ expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
+ }
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -49,6 +57,41 @@ describe('ensure-native-runtime', () => {
}
})
+ it.skipIf(process.platform !== 'win32')(
+ 'rebuilds other failed Windows addons with patched node-pty',
+ () => {
+ const projectDir = mkTempProject()
+
+ try {
+ const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
+ const logPath = join(projectDir, 'native-runtime.log')
+ const markerPath = join(projectDir, 'rebuilt.marker')
+ const binDir = join(projectDir, 'bin')
+ copyFileSync(sourceScriptPath, scriptPath)
+ writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
+ writeNodePtyPatchFile(projectDir)
+ writeFakePnpm(binDir)
+
+ const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ env: envWithPrependedPath(binDir, {
+ ORCA_NATIVE_TEST_LOG: logPath,
+ ORCA_NATIVE_TEST_MARKER: markerPath
+ })
+ })
+
+ expect(result.status, result.stderr).toBe(0)
+ const log = readFileSync(logPath, 'utf8')
+ expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ expect(log).toContain(join('node_modules', 'windows-native-registry'))
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true })
+ }
+ }
+ )
+
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -77,7 +120,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -111,7 +154,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -145,7 +188,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
- expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
+ expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -156,6 +199,10 @@ describe('ensure-native-runtime', () => {
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
+ copyFileSync(
+ sourceNodePtyJobOwnershipPath,
+ join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
+ )
return projectDir
}
@@ -171,9 +218,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
-function writeFakeNativeModules(projectDir) {
+function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -190,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
+ return {
+ dir: '../build/Release/',
+ module: {
+ listJobProcessIds() {},
+ terminateJob() {},
+ assignCurrentProcessToJob() {}
+ }
+ }
}
`
)
- writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -211,23 +278,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
- return { dir, module: {} }
+ return {
+ dir,
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+ }
}
`
)
writeFakeWindowsRegistry(projectDir)
}
-function writeFakeWindowsRegistry(projectDir) {
+function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
- join(registryDir, 'index.js'),
- 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
+ join(registryDir, 'package.json'),
+ '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
+ const markerGate = requiresMarker
+ ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
+ : ''
+ writeFileSync(
+ join(registryDir, 'index.js'),
+ `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
+ )
+ const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+ mkdirSync(processTreeDir, { recursive: true })
+ writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -238,6 +322,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -253,6 +344,15 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
+appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
+appendFileSync(
+ process.env.ORCA_NATIVE_TEST_LOG,
+ \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
+)
+appendFileSync(
+ process.env.ORCA_NATIVE_TEST_LOG,
+ \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
+)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs
new file mode 100644
index 00000000000..a90747513a1
--- /dev/null
+++ b/config/scripts/install-node-dependencies-action.test.mjs
@@ -0,0 +1,96 @@
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { delimiter, join } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { parse } from 'yaml'
+import { describe, expect, it } from 'vitest'
+
+const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
+const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run
+
+function run(command, args, options = {}) {
+ return spawnSync(command, args, { encoding: 'utf8', ...options })
+}
+
+function createFixture() {
+ const root = mkdtempSync(join(tmpdir(), 'orca-install-node-action-'))
+ const workspace = join(root, 'checkout')
+ const detachedCwd = join(root, 'action cwd')
+ const bin = join(root, 'bin')
+ mkdirSync(workspace)
+ mkdirSync(detachedCwd)
+ mkdirSync(bin)
+
+ for (const directory of [workspace, detachedCwd]) {
+ writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n')
+ writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
+ }
+
+ expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0)
+ expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0)
+
+ const pnpm = join(bin, 'pnpm')
+ writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
+ chmodSync(pnpm, 0o755)
+ return { bin, detachedCwd, root, workspace }
+}
+
+function executeInstallScript(fixture) {
+ return run('bash', ['-c', installScript], {
+ cwd: fixture.detachedCwd,
+ env: {
+ ...process.env,
+ GITHUB_WORKSPACE: fixture.workspace,
+ PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
+ }
+ })
+}
+
+describe('install-node-dependencies action', () => {
+ it('skips the lockfile diff when a job container has no Git metadata', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
+ it('skips the lockfile diff for a bare repository', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+ expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
+ it.each([
+ ['package.json', '{"name":"changed"}\n'],
+ ['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
+ ])('rejects a changed %s when the composite step cwd is detached', (file, contents) => {
+ const fixture = createFixture()
+ try {
+ const clean = executeInstallScript(fixture)
+ expect(clean.status, clean.stderr || clean.stdout).toBe(0)
+
+ writeFileSync(join(fixture.workspace, file), contents)
+ const dirty = executeInstallScript(fixture)
+ const output = `${dirty.stdout}\n${dirty.stderr}`
+ expect(dirty.status).toBe(1)
+ expect(output).toContain(`diff --git a/${file} b/${file}`)
+ expect(output).not.toContain('diff --git a/package.json b/pnpm-lock.yaml')
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/config/scripts/locale-ja-phrase-fixes.mjs b/config/scripts/locale-ja-phrase-fixes.mjs
index 504c5d25889..8c80544eba2 100644
--- a/config/scripts/locale-ja-phrase-fixes.mjs
+++ b/config/scripts/locale-ja-phrase-fixes.mjs
@@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [
export const JA_PHRASE_FIXES = [
...RELOCALIZED_GENERIC_TERMS,
- { pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
+ {
+ pattern: /エージェント/g,
+ replacement: 'Agent',
+ whenEnIncludes: 'agent',
+ // Skills filters and metadata are Japanese UI labels, not agent product prose.
+ skipKeyPrefixes: ['auto.components.skills.']
+ },
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
diff --git a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
index c91909ad69d..26ff48c878c 100644
--- a/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
+++ b/config/scripts/locale-translation-policy.ja-relocalization.test.mjs
@@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => {
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
})
+ it('preserves translated Japanese Skills labels', () => {
+ expect(
+ ja(
+ 'Filter by agent',
+ 'エージェントで絞り込み',
+ 'auto.components.skills.SkillsPage.filterProvider'
+ )
+ ).toBe('エージェントで絞り込み')
+ })
+
it('leaves ranges and token samples out of the ellipsis rule', () => {
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
})
diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs
index eb1a7ea0b92..9fd4350ee45 100644
--- a/config/scripts/locale-translation-policy.mjs
+++ b/config/scripts/locale-translation-policy.mjs
@@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) {
return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())
}
-function applyPhraseFixes(enValue, localeValue, locale) {
+function applyPhraseFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
if (!phraseFixMatchesEnglish(enValue, fix)) {
continue
}
+ if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) {
+ continue
+ }
result = result.replace(fix.pattern, fix.replacement)
}
return result
@@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
if (keyOverride) {
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
if (valueOverride) {
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
}
result = applyBrandMistranslationFixes(enValue, result, locale, key)
- result = applyPhraseFixes(enValue, result, locale)
+ result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs
new file mode 100644
index 00000000000..5ad578fd74a
--- /dev/null
+++ b/config/scripts/node-pty-job-ownership.cjs
@@ -0,0 +1,25 @@
+'use strict'
+
+const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
+
+function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
+ if (platform !== 'win32' || nativeName !== 'conpty') {
+ return
+ }
+ const exported = native?.module ?? native
+ const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
+ if (missing.length === 0) {
+ return
+ }
+ throw new Error(
+ [
+ `node-pty's conpty native is missing ${missing.join(', ')}.`,
+ `Resolved from: ${native?.dir ?? 'unknown'}`,
+ 'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
+ 'and pane teardown falls back to guessing by PID ancestry.',
+ 'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
+ ].join(' ')
+ )
+}
+
+module.exports = { assertNodePtyJobOwnership }
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index ef9745e476c..64b2f96505a 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
'utf8'
)
- expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
+ expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
})
it('keeps root postinstall as the single Electron binary install owner', () => {
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
- it('installs the Electron package binary in PR checks without changing native module ABI', () => {
- const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
- const parsedWorkflow = parse(prWorkflow)
- const installStep = parsedWorkflow.jobs.test.steps.find(
+ it('installs the Electron package binary in the shared unit-test workflow', () => {
+ const unitTestWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
+ )
+ const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
diff --git a/config/scripts/packaged-node-pty-prebuild-prune.test.mjs b/config/scripts/packaged-node-pty-prebuild-prune.test.mjs
new file mode 100644
index 00000000000..c0b24b8a8a2
--- /dev/null
+++ b/config/scripts/packaged-node-pty-prebuild-prune.test.mjs
@@ -0,0 +1,105 @@
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { createRequire } from 'node:module'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs')
+
+/**
+ * node-pty's loader tries build/Release, then build/Debug, then
+ * prebuilds/-, swallowing failures in between. Only the source
+ * build carries Orca's job-object exports, so leaving the prebuilt conpty.node
+ * beside it means an ABI mismatch or an AV quarantine silently downgrades the
+ * app to a binary that cannot own a PTY tree.
+ *
+ * The siblings must survive: Orca's patch deletes the `conpty_console_list` and
+ * winpty `pty` gyp targets, so those binaries exist nowhere but here.
+ */
+describe('prunePackagedNodePty: the Windows conpty fallback', () => {
+ let resources
+ const HOST_ARCH = process.arch
+
+ const nodePty = () => join(resources, 'node_modules', 'node-pty')
+ const write = (relative) => {
+ const target = join(nodePty(), relative)
+ mkdirSync(join(target, '..'), { recursive: true })
+ writeFileSync(target, 'x')
+ }
+ const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name)
+
+ /** What a real packaged win32 prebuilds/ directory holds. */
+ const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe']
+
+ const seedWindowsTree = (arch = HOST_ARCH) => {
+ write(join('build', 'Release', 'conpty.node'))
+ write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll'))
+ write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe'))
+ write(join('prebuilds', `win32-${arch}`, 'conpty.node'))
+ for (const sibling of SIBLINGS) {
+ write(join('prebuilds', `win32-${arch}`, sibling))
+ }
+ }
+
+ beforeEach(() => {
+ resources = mkdtempSync(join(tmpdir(), 'orca-prune-'))
+ })
+ afterEach(() => {
+ rmSync(resources, { recursive: true, force: true })
+ })
+
+ it('removes the stale conpty fallback when the source build is present', () => {
+ seedWindowsTree()
+
+ prunePackagedNodePty(resources, 'win32', HOST_ARCH)
+
+ expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true)
+ expect(existsSync(prebuilt('conpty.node'))).toBe(false)
+ })
+
+ it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => {
+ // Orca's patch removes the conpty_console_list and winpty gyp targets, so a
+ // Windows source build never produces these. Deleting them kills console
+ // membership silently and breaks PTY spawn below Windows build 18309.
+ seedWindowsTree()
+
+ prunePackagedNodePty(resources, 'win32', HOST_ARCH)
+
+ expect(existsSync(prebuilt(sibling))).toBe(true)
+ })
+
+ it('keeps the fallback when there is no source build to prefer', () => {
+ write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node'))
+ write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll'))
+ write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe'))
+
+ prunePackagedNodePty(resources, 'win32', HOST_ARCH)
+
+ expect(existsSync(prebuilt('conpty.node'))).toBe(true)
+ })
+
+ it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => {
+ // electron-builder --win --arm64 on an x64 host copies an x64
+ // build/Release; deleting the arm64 prebuild would remove the only binary
+ // the shipped app could load.
+ const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64'
+ seedWindowsTree(target)
+
+ prunePackagedNodePty(resources, 'win32', target)
+
+ expect(existsSync(prebuilt('conpty.node', target))).toBe(true)
+ })
+
+ it.each([
+ ['darwin', 'arm64'],
+ ['linux', 'x64']
+ ])('leaves %s prebuilds alone', (platform, arch) => {
+ write(join('build', 'Release', 'conpty.node'))
+ write(join('prebuilds', `${platform}-${arch}`, 'pty.node'))
+
+ prunePackagedNodePty(resources, platform, arch)
+
+ expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true)
+ })
+})
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index d19eee52454..c51b1c18a5c 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -16,6 +16,196 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
+export const PR_CHECK_JOBS = [
+ 'static_analysis',
+ 'typecheck',
+ 'git_compatibility',
+ 'xterm_patch_sync',
+ 'shell_contracts',
+ 'test',
+ 'orcad_browser',
+ 'cross-version-wire',
+ 'managed_hook_node18',
+ 'package',
+ 'package_windows'
+]
+
+const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
+
+const GLOBAL_FORCE_PREFIXES = [
+ '.github/workflows/pr.yml',
+ '.github/actions/install-node-dependencies/',
+ 'config/scripts/pr-code-change-scope'
+]
+
+const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
+
+const GIT_COMPAT_PREFIXES = [
+ 'src/shared/git-',
+ 'src/shared/review-head-tracking-ref',
+ 'src/main/git/',
+ 'src/relay/git-',
+ 'config/scripts/git-binary-compatibility'
+]
+
+const XTERM_PREFIXES = [
+ 'config/patches/xterm-upstream.json',
+ 'config/patches/@xterm',
+ 'config/patches/xterm-src/',
+ 'config/scripts/regenerate-xterm-patches'
+]
+
+const SHELL_PREFIXES = [
+ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
+ 'src/main/daemon/shell-ready',
+ 'src/main/daemon/daemon-bash-shell-ready',
+ 'src/main/daemon/daemon-shell-ready-wrapper',
+ 'src/main/daemon/node-pty-fd-leak',
+ 'src/main/providers/local-pty-shell-ready',
+ 'src/main/providers/__tests__/shell-ready-framework-example',
+ 'src/main/pty/',
+ 'src/main/shell-templates',
+ 'src/main/shell-startup-',
+ 'src/main/shell-wrapper-',
+ 'src/main/terminal-history-fish',
+ 'src/main/zsh-',
+ 'src/renderer/src/components/terminal-pane/fish-color-scheme',
+ 'src/shared/fish-',
+ 'src/shared/pty-reply-echo-shapes',
+ 'src/shared/startup-shell-portability',
+ 'src/shared/posix-command-path-lookup',
+ 'config/patches/node-pty@',
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/node-pty-job-ownership'
+]
+
+const ORCAD_BROWSER_PREFIXES = [
+ 'src/main/orcad/external-chromium-',
+ 'src/main/orcad/orcad-browser-provider',
+ 'src/main/orcad/orcad-agent-browser-binary',
+ 'src/main/orcad/electron-serve-browser-process'
+]
+
+const CROSS_VERSION_WIRE_PREFIXES = [
+ 'tests/e2e/cross-version-wire/',
+ 'src/shared/protocol-version',
+ 'src/shared/terminal-stream-protocol',
+ 'src/shared/browser-client-host-protocol',
+ 'src/shared/browser-network-tunnel-protocol',
+ 'src/shared/browser-client-host-placement',
+ 'src/main/runtime/rpc/dispatcher',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema',
+ 'src/main/runtime/rpc/methods/terminal',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
+]
+
+const MANAGED_HOOK_PREFIXES = [
+ 'config/scripts/smoke-managed-hook-runtime-node18',
+ 'config/scripts/build-relay',
+ 'src/relay/',
+ 'src/shared/agent-hook',
+ 'src/main/agent-hooks/'
+]
+
+const NATIVE_RUNTIME_PREFIXES = [
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/rebuild-native-deps',
+ 'config/scripts/node-pty-job-ownership',
+ 'config/scripts/electron-builder-native-rebuild',
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const NATIVE_CACHE_FILES = new Set([
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs'
+])
+
+const NATIVE_CACHE_PREFIXES = [
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const SHARED_PACKAGE_PREFIXES = [
+ 'electron.vite.config.ts',
+ 'config/electron-builder',
+ 'config/packaged-runtime',
+ 'config/build-plugins/',
+ 'config/scripts/build-',
+ 'config/scripts/smoke-packaged',
+ 'config/scripts/install-electron-package-binary',
+ 'config/scripts/verify-packaged',
+ 'config/scripts/verify-linux-glibc',
+ 'config/scripts/run-electron-vite',
+ 'skills/',
+ 'skill-guides/',
+ 'resources/build/',
+ 'resources/onboarding/',
+ 'resources/plugins/',
+ 'resources/skills/',
+ ...NATIVE_RUNTIME_PREFIXES
+]
+
+const LINUX_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/computer-use-linux/',
+ 'resources/linux/',
+ 'config/scripts/run-headless-serve'
+]
+
+const WINDOWS_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/windows-cli-launcher/',
+ 'native/computer-use-windows/',
+ 'resources/win32/',
+ 'config/scripts/build-windows-cli-launcher',
+ 'config/scripts/windows-pty-native-capability',
+ 'tests/tools/windows-pty-native-capability-smoke/'
+]
+
+const LINUX_PACKAGE_TESTS = [
+ 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
+ 'src/main/browser/browser-route-tcp-egress.electron.test.ts',
+ 'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
+ 'src/main/browser/browser-route-h3-egress.electron.test.ts',
+ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
+]
+
+const WINDOWS_PACKAGE_TESTS = [
+ ...LINUX_PACKAGE_TESTS,
+ 'config/scripts/rebuild-native-deps.test.mjs',
+ 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
+ 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
+ 'src/shared/child-process/windows-command-line.win32.test.ts',
+ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
+ 'src/main/windows/windows-pty-job.win32.test.ts',
+ 'src/main/windows/windows-host-job.win32.test.ts',
+ 'src/main/wsl/wsl-runner.test.ts',
+ 'src/main/wsl/wsl-guest-environment.test.ts',
+ 'src/main/wsl/wsl-invocation-boundary.test.ts',
+ 'src/main/wsl/wsl-executable-path.win32.test.ts',
+ 'src/main/wsl/wsl-w1-w3-contract.test.ts',
+ 'src/shared/source-scan/source-tree-scan.test.ts',
+ 'src/main/cli/wsl-cli-powershell-boundary.test.ts',
+ 'src/main/cursor/hook-service.test.ts',
+ 'src/main/orca-profiles/profile-index-store.test.ts',
+ 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
+ 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
+ 'src/shared/secure-file-fsync-flags.test.ts',
+ 'src/main/ipc/pty-codex-account-attribution.test.ts',
+ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
+]
+
+const DESKTOP_IRRELEVANT_PREFIXES = [
+ 'mobile/',
+ '.github/workflows/mobile.yml',
+ '.github/workflows/mobile-ios-release.yml',
+ '.github/workflows/mobile-android-release.yml'
+]
+
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +222,91 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
- return changedFiles.some((file) => !isDocsOnlyPath(file))
+ return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
+}
+
+export function classifyPrJobs(changedFiles) {
+ const emptyDiff = changedFiles.length === 0
+ const shouldRun = shouldRunPrChecks(changedFiles)
+ const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
+ const jobs = Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
+ ])
+ )
+ return {
+ should_run: shouldRun,
+ native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
+ ...jobs
+ }
+}
+
+function jobDetector(job) {
+ switch (job) {
+ case 'git_compatibility':
+ return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
+ case 'xterm_patch_sync':
+ return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
+ case 'shell_contracts':
+ return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
+ case 'orcad_browser':
+ return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
+ case 'cross-version-wire':
+ return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
+ case 'managed_hook_node18':
+ return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
+ case 'package':
+ return (files) => files.some(isLinuxPackagePath)
+ case 'package_windows':
+ return (files) => files.some(isWindowsPackagePath)
+ default:
+ return () => true
+ }
+}
+
+function isLinuxPackagePath(file) {
+ return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
+}
+
+function isWindowsPackagePath(file) {
+ return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
+}
+
+function isProductBundlePath(file, extraPrefixes) {
+ if (isTestFile(file)) {
+ return false
+ }
+ if (file.startsWith('src/')) {
+ return true
+ }
+ return matchesPrefix(file, extraPrefixes)
+}
+
+function isTestFile(file) {
+ return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
+}
+
+function isDesktopIrrelevantPath(file) {
+ return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
+}
+
+function isNativeCacheInputPath(file) {
+ return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
+}
+
+function isGlobalForcePath(file) {
+ return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
+}
+
+function matchesPrefix(file, prefixes) {
+ return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
- process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
+ const classification = classifyPrJobs(files)
+ for (const [name, value] of Object.entries(classification)) {
+ process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+ }
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index f71d6c42934..ea83796e6cb 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -1,13 +1,18 @@
+import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
-import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
+import {
+ classifyPrJobs,
+ isDocsOnlyPath,
+ PR_CHECK_JOBS,
+ shouldRunPrChecks
+} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
-const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
+ 'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
+const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
+
+function expectedJobs(overrides, { alwaysOn = true } = {}) {
+ return Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ (alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
+ ])
+ )
+}
+
+function expectClassification(files, overrides) {
+ const result = classifyPrJobs(files)
+ const shouldRun = shouldRunPrChecks(files)
+ expect(result.should_run).toBe(shouldRun)
+ expect(result).toMatchObject({
+ should_run: shouldRun,
+ ...expectedJobs(overrides, { alwaysOn: shouldRun })
+ })
+}
+
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,170 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
+
+ it('does not start desktop PR Checks for mobile-only diffs', () => {
+ expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
+ })
})
-describe('PR Checks docs-only skip wiring', () => {
+describe('per-job path classification', () => {
+ it('runs every expensive job on an empty diff rather than skipping by accident', () => {
+ const result = classifyPrJobs([])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ })
+
+ it('skips every expensive job for docs-only diffs', () => {
+ expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
+ })
+
+ it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
+ expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs Git compatibility only when git capability inputs change', () => {
+ expectClassification(['src/shared/git-capability-cache.ts'], {
+ git_compatibility: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('runs xterm patch sync only when xterm inputs change', () => {
+ expectClassification(['config/patches/xterm-upstream.json'], {
+ xterm_patch_sync: true
+ })
+ expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
+ xterm_patch_sync: true
+ })
+ })
+
+ it('runs native package jobs only for the platform that ships the changed native', () => {
+ expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
+ package_windows: true
+ })
+ expectClassification(['native/computer-use-linux/runtime.py'], {
+ package: true
+ })
+ expectClassification(['native/computer-use-macos/Package.swift'], {})
+ })
+
+ it('runs shell contracts when live-shell inputs change', () => {
+ expectClassification(['src/main/daemon/shell-ready.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
+ expectClassification(['src/main/shell-templates.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
+ for (const file of [
+ 'src/main/orcad/external-chromium-browser-session.ts',
+ 'src/main/orcad/external-chromium-command-arguments.ts',
+ 'src/main/orcad/external-chromium-tab-registry.ts',
+ 'src/main/orcad/external-chromium-tab-projection.ts'
+ ]) {
+ expectClassification([file], {
+ orcad_browser: true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs cross-version wire checks for every working-tree wire module', () => {
+ for (const file of [
+ 'src/shared/protocol-version.ts',
+ 'src/shared/terminal-stream-protocol.ts',
+ 'src/main/runtime/rpc/dispatcher.ts',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
+ 'src/main/runtime/rpc/methods/terminal.ts',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
+ ]) {
+ expectClassification([file], {
+ 'cross-version-wire': true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(
+ ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
+ { 'cross-version-wire': true }
+ )
+ })
+
+ it('runs workflow-self-change and lockfile diffs as force-all', () => {
+ const result = classifyPrJobs(['.github/workflows/pr.yml'])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
+ })
+
+ it('primes native caches only when their immutable inputs change', () => {
+ expect(classifyPrJobs([]).native_cache_changed).toBe(true)
+ expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
+ expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
+ for (const file of [
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs',
+ 'config/patches/node-pty@1.1.0.patch'
+ ]) {
+ expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
+ }
+ })
+
+ it('keeps unit-test-only diffs out of packaging', () => {
+ expectClassification(['src/main/git/git-status.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('emits GitHub output pairs from the shipped CLI', () => {
+ const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ input: 'config/patches/xterm-upstream.json\n'
+ })
+ expect(result.status, result.stderr).toBe(0)
+ expect(result.stdout).toContain('should_run=true\n')
+ expect(result.stdout).toContain('xterm_patch_sync=true\n')
+ expect(result.stdout).toContain('git_compatibility=false\n')
+ expect(result.stdout).toContain('package=false\n')
+ expect(result.stdout).toContain('test=true\n')
+ })
+})
+
+describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +253,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
- expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
- '${{ steps.filter.outputs.should_run }}'
- )
+ expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
+ for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
+ expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
+ `\${{ steps.filter.outputs.${jobName} }}`
+ )
+ }
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,11 +266,26 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
- it('skips expensive jobs unless the detector says the PR has code', () => {
- for (const jobName of expensiveJobs) {
+ it('gates each expensive job on its classifier and cache prerequisite', () => {
+ for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
- expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
+ expect(prWorkflow.jobs[jobName].if, jobName).toBe(
+ `needs.code_paths.outputs.${jobName} == 'true'`
+ )
}
+ expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
+ expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
+ expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
+ expect(prWorkflow.jobs.test_native_cache.if).toBe(
+ "needs.code_paths.outputs.native_cache_changed == 'true'"
+ )
+ expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
+ const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
@@ -89,19 +295,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
- it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
+ it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
- expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
- const docsOnlyBranch = verifyStep.run.slice(
- 0,
- verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
- )
- expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
- expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
+ expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
+ expect(verifyStep.run).toContain('expected skipped')
+ expect(verifyStep.run).toContain('expected success')
+ for (const job of prWorkflow.jobs.verify.needs) {
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
+ const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
+ expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
+ expect(verifyStep.run).toContain(`"$${envVar}"`)
+ }
})
})
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 92faae907a1..8cd622b300c 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,11 +1,22 @@
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
+import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
-import { parse } from 'yaml'
+import { parse as parseYaml } from 'yaml'
+import {
+ hasSshSourceChange,
+ PR_E2E_SOURCE_ROUTES,
+ selectPrE2eSpecs,
+ SSH_SOURCE_ROUTE_IDS
+} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
-const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
-const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
+const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
+const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
+const reliabilityManifest = parseJsonc(
+ readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
+)
+const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
const sshDockerRunner = readFileSync(
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
'utf8'
@@ -21,6 +32,47 @@ const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
+/** The route that sends a change to the two-Electron restart-survival spec. */
+const restartSurvivalRoute = PR_E2E_SOURCE_ROUTES.find(
+ (route) => route.id === 'client-hosted-browser.restart-survival'
+)
+
+describe('restart-survival E2E routing', () => {
+ // Every file below carries behavior the restart spec is the only test that exercises end to end.
+ it.each([
+ 'src/main/runtime/orca-runtime.ts',
+ 'src/main/runtime/orca-runtime-browser.ts',
+ 'src/main/runtime/client-hosted-page-reconciliation-window.ts',
+ 'src/main/runtime/runtime-browser-client-page-adoption.ts',
+ 'src/main/runtime/runtime-browser-client-page-recovery.ts',
+ 'src/main/runtime/browser-host-client-page-adoption.ts',
+ 'src/main/runtime/browser-host-page-reconciliation-orchestration.ts',
+ 'src/main/runtime/rpc/methods/browser-client-host.ts',
+ 'src/main/browser/browser-client-host-authority-replacement-wait.ts',
+ 'src/main/browser/paired-runtime-browser-client-host-composition.ts',
+ 'src/renderer/src/runtime/web-session-tabs-sync.ts',
+ 'src/renderer/src/runtime/host-session-snapshot-authority.ts',
+ 'src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts',
+ 'src/renderer/src/store/slices/runtime-status.ts',
+ 'src/shared/runtime-types.ts',
+ 'src/shared/browser-client-host-protocol.ts'
+ ])('routes %s', (path) => {
+ expect(restartSurvivalRoute.matches(path)).toBe(true)
+ })
+
+ // The pattern is deliberately not "anything under src": routing every PR at a two-Electron spec
+ // is the cost the filter exists to avoid.
+ it.each([
+ 'src/main/git/git-status.ts',
+ 'src/renderer/src/components/tab-bar/BrowserTab.tsx',
+ 'src/main/terminal/pty-manager.ts',
+ // The status/types entries name whole files, not a suffix any longer name may end with.
+ 'src/shared/computer-use-runtime-types.ts'
+ ])('does not route %s', (path) => {
+ expect(restartSurvivalRoute.matches(path)).toBe(false)
+ })
+})
+
describe('PR E2E gate contract', () => {
it('keeps E2E advisory while the suite is red on main', () => {
// Why: pin the deliberate choice so it reads as intentional rather than as
@@ -53,24 +105,33 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
- // Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
- const successLoop = verifyStep.run.slice(
- verifyStep.run.indexOf(successMarker),
- verifyStep.run.lastIndexOf('done')
- )
+ const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
+ expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
+ expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
- const envVar = job.toUpperCase()
+ const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
expect(successLoop).toContain(`"$${envVar}"`)
+ expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
- expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
+ expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs')
expect(filterStep.run).not.toContain('tests/playwright\\.')
+ expect(
+ selectPrE2eSpecs([
+ 'tests/e2e/active-view-restart-restore.spec.ts',
+ 'tests/e2e/deleted.spec.ts.bak',
+ 'tests/e2e/global-teardown.unit.test.ts'
+ ])
+ ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
})
it('uses one runner for changed specs and keeps full runs sharded', () => {
@@ -89,6 +150,7 @@ describe('PR E2E gate contract', () => {
expect(changedRun.run).toContain(
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
)
+ expect(playwrightConfig).toContain('retries: 0')
})
it('keeps startup-exec live parity in the isolated SSH lane', () => {
@@ -102,6 +164,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
+ it('reuses the composite install action instead of duplicating pnpm setup', () => {
+ const installFor = (jobName) =>
+ e2eWorkflow.jobs[jobName].steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+
+ expect(installFor('build').with['native-runtime']).toBe('node')
+ for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
+ expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
+ }
+ })
+
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
@@ -121,7 +195,7 @@ describe('PR E2E gate contract', () => {
]
for (const file of dedicatedWorkflows) {
- const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
+ const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
expect(workflow.on.pull_request, file).toBeUndefined()
}
})
@@ -138,23 +212,41 @@ describe('PR E2E gate contract', () => {
const sshSourceAuthorities = [
'src/main/ssh/',
'src/main/providers/ssh-',
- 'src/main/ipc/(ssh-|pty)',
+ 'src/main/ipc/pty',
'src/relay/',
- 'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
+ 'src/shared/ssh-',
+ 'src/renderer/src/store/slices/direct-ssh-',
+ 'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
- expect(filterStep.run).toContain(authority)
+ expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain(
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
+ )
+ }
+
+ // Why named files rather than prefixes: these seams are single modules, and a prefix
+ // here would route their unrelated neighbours.
+ for (const file of [
+ 'src/main/runtime/public-ssh-state.ts',
+ 'src/renderer/src/startup/ssh-startup-reconnect.ts',
+ 'src/renderer/src/store/slices/ssh.ts'
+ ]) {
+ expect(selectPrE2eSpecs([file]), file).toContain(
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
+ )
}
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
for (const spec of mappedSpecs) {
- expect(filterStep.run).toContain(`'${spec}'`)
+ expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec)
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
// Why: a spec that stops reading the flag would silently run without Docker.
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
@@ -162,8 +254,7 @@ describe('PR E2E gate contract', () => {
}
}
- // Why: unit tests next to the mapped sources prove the same code without Docker.
- expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
+ expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([])
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
// meaningful while it still routes the dedicated Docker lane.
@@ -179,10 +270,237 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
+ it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
+ // Why by name: none of these carry "ssh", so the SSH authorities above never reach them
+ // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
+ for (const file of [
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts',
+ 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
+ 'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
+ 'src/shared/remote-workspace-session-projection.ts',
+ 'src/renderer/src/components/terminal/initial-terminal.ts'
+ ]) {
+ const specs = selectPrE2eSpecs([file])
+ expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
+ expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
+ }
+
+ expect(
+ selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
+ ).toEqual([])
+ })
+
+ it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
+ // The behavioural half of the invariant, and the part that actually matters: an SSH source
+ // edit is recognised as one, through the same routes that select the specs.
+ for (const file of [
+ 'src/main/ssh/connection.ts',
+ 'src/relay/pty-handler.ts',
+ 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(true)
+ }
+ for (const file of [
+ 'src/main/git/git-status.ts',
+ 'src/renderer/src/components/tab-bar/BrowserTab.tsx',
+ 'src/main/ssh/connection.test.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(false)
+ }
+
+ // Why: the signal must stay derived from the routes. A route id that no longer exists would
+ // silently narrow it to nothing.
+ for (const id of SSH_SOURCE_ROUTE_IDS) {
+ expect(
+ PR_E2E_SOURCE_ROUTES.map((route) => route.id),
+ id
+ ).toContain(id)
+ }
+
+ // Why text and not structure: a job `if:` is only ever available as a string. The strongest
+ // available assertion is that the source signal is its own disjunct, so the lane no longer
+ // depends on a spec name surviving in a route's spec list.
+ const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
+ expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
+
+ expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
+ expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
+ '${{ steps.filter.outputs.ssh_source_changed }}'
+ )
+ expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
+ '${{ needs.e2e-paths.outputs.ssh_source_changed }}'
+ )
+ expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
+ expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
+ })
+
+ it('gives every Docker-gated SSH spec a lane that runs it', () => {
+ // Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
+ // that no runner names runs nowhere and still reports green — the silent skip this file
+ // exists to prevent. Asserting reachability rather than a literal keeps that true when
+ // the lanes move.
+ // Why these two are exempt: each needs something CI cannot give it, recorded in
+ // run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
+ const unreachableSpecs = new Set([
+ 'tests/e2e/ssh-docker-relay-perf.spec.ts',
+ 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
+ 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
+ ])
+ // Why comments are stripped: this file's own runner lists the two exempt specs by name in a
+ // prose comment. A substring scan over raw text would count any spec merely *discussed* in a
+ // runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
+ // through the documentation.
+ const stripComments = (text) =>
+ text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ const laneRunners = [
+ 'run-ssh-docker-e2e.mjs',
+ 'run-ssh-docker-watcher-isolation-e2e.mjs',
+ 'run-ssh-docker-terminal-parking-e2e.mjs'
+ ].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
+
+ // Why a comparison and not the bare name: preview and demo specs cite the flag in a
+ // "how to run me" comment without gating on it. Why a regex rather than one literal: an
+ // equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
+ // and the spec would silently leave the contract.
+ const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
+ const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
+ .filter((file) => file.endsWith('.spec.ts'))
+ .map((file) => `tests/e2e/${file}`)
+ .filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
+ expect(dockerGatedSpecs.length).toBeGreaterThan(0)
+
+ const unclaimed = dockerGatedSpecs.filter(
+ (spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
+ )
+ expect(
+ unclaimed,
+ `Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
+ ).toEqual([])
+
+ // Why: an exemption that outlives its spec would quietly excuse a real gap.
+ for (const spec of unreachableSpecs) {
+ expect(dockerGatedSpecs, spec).toContain(spec)
+ // Why also assert absence from every runner: `unreachableSpecs` short-circuits the
+ // unclaimed check above, so a spec could be documented as exempt while a runner still
+ // invokes it -- an exemption that reads as coverage removal but changes nothing, and a
+ // lane that stays red for a reason the file says it excluded.
+ for (const runner of laneRunners) {
+ expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
+ false
+ )
+ }
+ }
+
+ const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
+ (step) => step.name === 'Run remaining Docker SSH E2E'
+ )
+ expect(laneStep.run).toContain('test:e2e:ssh-docker')
+ // Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
+ expect(
+ e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
+ ).toBeGreaterThanOrEqual(60)
+ })
+
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
- expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
+ expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([
+ 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'
+ ])
+ })
+
+ it('routes P0 sentinels from their causal sources', () => {
+ const cases = [
+ [
+ 'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx',
+ 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
+ ],
+ ['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'],
+ [
+ 'src/renderer/src/runtime/sync-runtime-graph.ts',
+ 'tests/e2e/host-parked-pane-remote-viewer.spec.ts'
+ ],
+ [
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
+ 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
+ ],
+ [
+ 'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts',
+ 'tests/e2e/paired-remote-pane-layout-retry.spec.ts'
+ ]
+ ]
+ for (const [source, spec] of cases) {
+ expect(selectPrE2eSpecs([source]), source).toEqual([spec])
+ expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
+ expect(existsSync(join(projectDir, spec)), spec).toBe(true)
+ }
+ const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
+ for (const source of [
+ 'src/renderer/src/components/terminal-pane/pty-connection.ts',
+ 'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts',
+ 'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts',
+ 'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts',
+ 'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts'
+ ]) {
+ expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec)
+ expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
+ quickCommandSpec
+ )
+ }
+ for (const source of [
+ 'src/main/ipc/rg-availability.ts',
+ 'src/shared/ripgrep-process-availability.ts'
+ ]) {
+ expect(selectPrE2eSpecs([source]), source).toEqual([
+ 'tests/e2e/paired-quick-open-large-tree.spec.ts'
+ ])
+ }
+ expect(
+ selectPrE2eSpecs([
+ 'src/main/runtime/orca-runtime-files.ts',
+ 'tests/e2e/paired-quick-open-large-tree.spec.ts'
+ ])
+ ).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts'])
+ expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([])
+ expect(
+ selectPrE2eSpecs([
+ 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts'
+ ])
+ ).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
+ expect(
+ selectPrE2eSpecs([
+ 'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
+ ])
+ ).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
+ expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
+ 'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
+ )
+ })
+
+ it('keeps source-routed sentinels registered to their reliability gates', () => {
+ const routedGateIds = [
+ 'terminal-startup.quick-command-pre-bind-recovery',
+ 'quick-open.paired-host-path-search',
+ 'terminal-session.host-cold-park-stream-continuity',
+ 'terminal-provider.ssh-remote-reattach-contract',
+ 'terminal-session.remote-pane-layout-retry'
+ ]
+ for (const gateId of routedGateIds) {
+ const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
+ const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
+ expect(route, gateId).toBeDefined()
+ expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
+ for (const spec of route.specs) {
+ expect(gate.testFiles, gateId).toContain(spec)
+ expect(
+ gate.commands.some((command) => command.includes(spec)),
+ gateId
+ ).toBe(true)
+ }
+ }
})
})
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
new file mode 100644
index 00000000000..108a4d5ab10
--- /dev/null
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -0,0 +1,179 @@
+import process from 'node:process'
+import { pathToFileURL } from 'node:url'
+
+const isProductSource = (file) => !/\.test\.tsx?$/.test(file)
+
+export const PR_E2E_SOURCE_ROUTES = [
+ {
+ id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
+ specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
+ matches: (file) =>
+ /^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test(
+ file
+ )
+ },
+ {
+ id: 'ssh-terminal-source',
+ specs: [
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts',
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
+ 'tests/e2e/ssh-startup-exec-readiness.spec.ts',
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
+ ],
+ // Why the store/startup/shared additions: the SSH-named authorities stop at the main
+ // process and the pane component, but the reconnect ledgers and retained-payload
+ // admission that decide whether a pane rebinds live in the renderer store.
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ file
+ )
+ },
+ {
+ // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
+ // no "ssh" in their names, and only the two restore specs gate them. Folding them in
+ // would run the whole SSH terminal list for a tab-tombstone edit.
+ id: 'ssh-workspace-session-restore',
+ specs: [
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
+ ],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
+ file
+ )
+ },
+ {
+ id: 'terminal-input.ime-and-synthetic-forwarding',
+ specs: [
+ 'tests/e2e/terminal-cjk-ime-committed-text.spec.ts',
+ 'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts',
+ 'tests/e2e/terminal-ime-exact-byte.spec.ts',
+ 'tests/e2e/terminal-korean-composing-chord-order.spec.ts',
+ 'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts',
+ 'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts',
+ 'tests/e2e/terminal-korean-preedit-visibility.spec.ts'
+ ],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test(
+ file
+ )
+ },
+ {
+ id: 'terminal-startup.quick-command-pre-bind-recovery',
+ specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
+ file
+ )
+ },
+ {
+ id: 'quick-open.paired-host-path-search',
+ specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test(
+ file
+ )
+ },
+ {
+ id: 'terminal-session.host-cold-park-stream-continuity',
+ specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test(
+ file
+ )
+ },
+ {
+ id: 'terminal-provider.ssh-remote-reattach-contract',
+ specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ !file.endsWith('-test-harness.ts') &&
+ /^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test(
+ file
+ )
+ },
+ {
+ id: 'terminal-session.remote-pane-layout-retry',
+ specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
+ file
+ )
+ },
+ {
+ // Why: the host's row for a client-rendered page only exists across two real Electron
+ // apps, so this spec is the only gate on it. The high-churn seams it also rides
+ // (ipc/runtime, useIpcEvents, preload) are left out deliberately: routing on those runs a
+ // two-app e2e on most PRs, and their client-hosted share is already covered by the
+ // main-process integration test.
+ id: 'client-hosted-browser.host-strip',
+ specs: ['tests/e2e/paired-client-hosted-browser-host-strip.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^src\/.*(?:[Cc]lient-?[Hh]osted-?[Bb]rowser|BrowserPaneOverlayLayer)/.test(file)
+ },
+ {
+ // Why a second, wider pattern: restart survival breaks from seams that never say
+ // "client-hosted" - page adoption, the host lease/reconciliation plan, the session-tab
+ // snapshot the client culls rows against. orca-runtime.ts is included despite its churn: it
+ // publishes the snapshot flag the client holds its rows on, and no narrower path names that
+ // seam.
+ id: 'client-hosted-browser.restart-survival',
+ specs: ['tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test(
+ file
+ )
+ }
+]
+
+export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
+ const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
+ for (const route of PR_E2E_SOURCE_ROUTES) {
+ const matchedFiles = changedPaths.filter(route.matches)
+ if (matchedFiles.length === 0) {
+ continue
+ }
+ route.specs.forEach((spec) => specs.add(spec))
+ reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`)
+ }
+ return [...specs].sort((left, right) => left.localeCompare(right))
+}
+
+/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
+export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
+
+// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
+// trigger only because one route happened to list a startup-readiness spec, so pruning that
+// spec would have silently retired the lane. Two lists that must agree is how that drifted.
+export function hasSshSourceChange(changedPaths) {
+ return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
+ (route) => changedPaths.some(route.matches)
+ )
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ let input = ''
+ process.stdin.setEncoding('utf8')
+ for await (const chunk of process.stdin) {
+ input += chunk
+ }
+ const changedPaths = input.split(/\r?\n/).filter(Boolean)
+ if (process.argv.includes('--ssh-source')) {
+ process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
+ } else {
+ const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
+ process.stdout.write(`${JSON.stringify(specs)}\n`)
+ }
+}
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 4b3e0917906..29f532b9436 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
+const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
- it('shards the general test suite across Node 24 and Node 26', () => {
- expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
- expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
- Array.from({ length: 16 }, (_, index) => index + 1)
+ it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
+ const sharedTest = unitTestWorkflow.jobs.test
+ const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
+ const installStep = sharedTest.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
)
- expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
- const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
- const installStep = workflow.jobs.test.steps.find(
+ const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
+ expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
+ expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
+ expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
+ expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
+ expect(sharedTest.strategy.matrix.shard).toEqual(
+ Array.from({ length: 8 }, (_, index) => index + 1)
+ )
+ expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
+ expect(primerInstall.with['native-runtime']).toBe('node')
+ expect(primerInstall.with['node-version']).toBe('24')
+ expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
+ const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
+ expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
- for (const jobName of [
- 'static_analysis',
- 'typecheck',
- 'git_compatibility',
- 'xterm_patch_sync'
- ]) {
+ for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
+ expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
- expect(installFor('test').with['native-runtime']).toBe('node')
+ expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
+ expect(installFor('package_windows').with['native-runtime']).toBe('node')
+ expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
+ expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
- ).toBe("inputs.native-runtime != 'none'")
+ ).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -256,7 +275,9 @@ describe('PR workflow parallelism', () => {
// resolution can never legitimately change anything.
expect(dependencyInstall.run).toContain('--frozen-lockfile')
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
- expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
+ expect(dependencyInstall.run).toContain(
+ 'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml'
+ )
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
@@ -293,14 +314,42 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
- expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
+ expect(steps[cacheIndex].if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
+ )
+ const restoreOnly = steps.find(
+ (step) => step.name === 'Restore compiled native modules without saving'
+ )
+ expect(restoreOnly.if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
+ )
+ expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
- expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
- expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
- expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
- // No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
- expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
+ for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
+ expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
+ expect(cacheStep.with.key).toContain('${{ runner.os }}')
+ expect(cacheStep.with.key).toContain('${{ runner.arch }}')
+ expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
+ expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
+ expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
+ expect(cacheStep.with.key).toContain(
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
+ )
+ expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
+ expect(cacheStep.with.path).toContain('windows-native-registry@')
+ expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
+ expect(cacheStep.with['restore-keys']).toBeUndefined()
+ }
+ const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
+ expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
+ expect(cacheScope.run).toContain('/etc/os-release')
+ expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
+ '${{ steps.native-cache-scope.outputs.scope }}'
+ )
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -343,6 +392,8 @@ describe('PR workflow parallelism', () => {
'xterm_patch_sync',
'shell_contracts',
'test',
+ 'orcad_browser',
+ 'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -352,5 +403,11 @@ describe('PR workflow parallelism', () => {
)
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
+ // Why assert this one too: the browser provider test skips itself without
+ // ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
+ expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
+ expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
+ expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
+ expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs
index 6d776abd1ca..4eca4bac33a 100644
--- a/config/scripts/rebuild-native-deps.mjs
+++ b/config/scripts/rebuild-native-deps.mjs
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
- // load successfully in Electron while missing the patched fd/error handling.
+ // Why: Orca patches node-pty's native Unix spawn path and Windows job-object
+ // exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths =
+ rebuildPlatform === 'win32'
+ ? [
+ resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
+ resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
+ )
+ ]
+ : [
+ resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
+ ...(osPlatform() === 'darwin'
+ ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
+ : [])
+ ]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
- if (rebuildPlatform === 'win32') {
- return false
- }
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
@@ -475,9 +481,14 @@ function loadNativeModule(moduleName) {
}
if (moduleName === 'node-pty') {
projectRequire('node-pty')
+ const { assertNodePtyJobOwnership } = projectRequire(
+ './config/scripts/node-pty-job-ownership.cjs'
+ )
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
- const native = loadNativeModule(getNodePtyNativeModuleName())
+ const nativeName = getNodePtyNativeModuleName()
+ const native = loadNativeModule(nativeName)
assertNodePtyWindowsConptyRuntime(native.dir)
+ assertNodePtyJobOwnership({ nativeName, native })
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
throw new Error(
'node-pty resolved to ' +
diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs
index 0ab28de7a65..5ebc39dfcaa 100644
--- a/config/scripts/rebuild-native-deps.test.mjs
+++ b/config/scripts/rebuild-native-deps.test.mjs
@@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo
const sourceInstallScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
+const sourceNodePtyJobOwnershipPath = fileURLToPath(
+ new URL('./node-pty-job-ownership.cjs', import.meta.url)
+)
describe('rebuild-native-deps Electron install fallback', () => {
it('continues non-strict postinstall when Electron retry download fails', () => {
@@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsProcessTree(projectDir)
writeFakeNodePtyConptyPayload(projectDir, process.arch)
const result = runRebuildScript(projectDir, {
@@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir)
+ writeFakeWindowsProcessTree(projectDir)
writeFakeNodePtyConptyPayload(projectDir, process.arch)
const result = runRebuildScript(projectDir, {
@@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
}
)
+ it.skipIf(process.platform !== 'win32')(
+ 'rebuilds a loadable ConPTY native that lacks Orca job ownership',
+ () => {
+ const projectDir = mkTempProject()
+
+ try {
+ const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
+ writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
+ writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
+ writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
+ writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsProcessTree(projectDir)
+
+ const result = runRebuildScript(projectDir, {
+ ORCA_REBUILD_TEST_LOG: rebuildLogPath,
+ npm_config_platform: 'win32',
+ npm_config_arch: process.arch
+ })
+
+ expect(result.status, result.stderr).toBe(0)
+ expect(result.stdout).toContain('Rebuilding failed native modules: node-pty')
+ expect(result.stdout).toContain('missing listJobProcessIds')
+ const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
+ expect(rebuildCall.onlyModules).toEqual(['node-pty'])
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true })
+ }
+ }
+ )
+
it.skipIf(process.platform === 'win32')(
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
() => {
@@ -305,6 +340,10 @@ function mkTempProject() {
sourceInstallScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
+ copyFileSync(
+ sourceNodePtyJobOwnershipPath,
+ join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
+ )
return projectDir
}
@@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) {
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
}
-function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
+function writeFakeLoadableNodePty(
+ projectDir,
+ { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
+) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
@@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
join(nodePtyDir, 'lib', 'utils.js'),
`
exports.loadNativeModule = function loadNativeModule(nativeName) {
- return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
+ return {
+ dir: ${JSON.stringify(nativeDir)},
+ module: {
+ nativeName,
+ ...(${JSON.stringify(ownsPtyJob)}
+ ? {
+ listJobProcessIds() {},
+ terminateJob() {},
+ assignCurrentProcessToJob() {}
+ }
+ : {})
+ }
+ }
}
`
)
@@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) {
)
}
+function writeFakeWindowsProcessTree(projectDir) {
+ const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+ mkdirSync(processTreeDir, { recursive: true })
+ writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
+}
+
function writeNodePtyPatchFile(projectDir) {
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
@@ -526,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
diff --git a/config/scripts/regenerate-xterm-patches.test.mjs b/config/scripts/regenerate-xterm-patches.test.mjs
index 50c309f21b0..ac9f9a7ca52 100644
--- a/config/scripts/regenerate-xterm-patches.test.mjs
+++ b/config/scripts/regenerate-xterm-patches.test.mjs
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
+ it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
+ const patch = await readFile(
+ path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
+ 'utf8'
+ )
+ expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
+ expect(
+ patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
+ ).toHaveLength(3)
+ expect(
+ patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
+ ).toHaveLength(3)
+ })
+
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs
index 18fe76c4c44..d48c7b3fa30 100644
--- a/config/scripts/release-e2e-dispatch-contract.test.mjs
+++ b/config/scripts/release-e2e-dispatch-contract.test.mjs
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
describe('release E2E dispatch contract', () => {
+ it('validates immutable tags with the current golden test harness', () => {
+ const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
+ (step) => step.name === 'Restore golden test harness from the workflow ref'
+ )
+
+ expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
+ expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
+ expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
+ expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
+ })
+
it('dispatches tag-scoped E2E only after publication', () => {
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts
index 629ba1fddde..8c38b312a1d 100644
--- a/config/scripts/remote-shared-control-retirement-probe.ts
+++ b/config/scripts/remote-shared-control-retirement-probe.ts
@@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: MemorySnapshot['app']
host: MemorySnapshot['host']
processMemoryMetric: MemorySnapshot['processMemoryMetric']
+ processCommitMetric: MemorySnapshot['processCommitMetric']
totalCpu: number
totalMemory: number
+ totalPrivateMemory: MemorySnapshot['totalPrivateMemory']
worktreeCount: number
sessionCount: number
worktreeMemory: number
@@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: snapshot.app,
host: snapshot.host,
processMemoryMetric: snapshot.processMemoryMetric,
+ processCommitMetric: snapshot.processCommitMetric,
totalCpu: snapshot.totalCpu,
totalMemory: snapshot.totalMemory,
+ totalPrivateMemory: snapshot.totalPrivateMemory,
worktreeCount: snapshot.worktrees.length,
sessionCount: snapshot.worktrees.reduce(
(total, worktree) => total + worktree.sessions.length,
diff --git a/config/scripts/run-local-ssh-browser-routing-e2e.mjs b/config/scripts/run-local-ssh-browser-routing-e2e.mjs
new file mode 100644
index 00000000000..4562e21e3b6
--- /dev/null
+++ b/config/scripts/run-local-ssh-browser-routing-e2e.mjs
@@ -0,0 +1,42 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1',
+ ORCA_E2E_LOCAL_SSH_BROWSER: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/local-ssh-browser-routing.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs b/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs
new file mode 100644
index 00000000000..fc1fd5a4e99
--- /dev/null
+++ b/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs
@@ -0,0 +1,42 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1',
+ ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
new file mode 100644
index 00000000000..a723a9a6ad0
--- /dev/null
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -0,0 +1,95 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1',
+ ORCA_E2E_WEB_CLIENT: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
+// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
+// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
+//
+// Deliberately absent, and therefore still covered by no CI trigger:
+// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
+// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
+// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
+// all. Recorded as a real gap, not as coverage living somewhere else.
+// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
+// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
+// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
+// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
+// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
+// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
+// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
+// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
+// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
+// semantic decisions (which ptyId to capture, which split direction) that change
+// what the repro measures. Tracked in stablyai/orca#16764.
+//
+// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
+// grep-inverts away.
+//
+// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
+// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
+// isolation is now one spec of many. Renaming it changes the GitHub check name and can
+// break required-check config, so the name understates the job on purpose.
+// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
+// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
+// has the exact wiring to flip it, and the gate contract asserts the current state.
+// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
+// and are not Docker-gated, which puts them outside this file's contract:
+// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
+// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
+// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
+// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
+// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
+// Runner scripts for the first four sit unused in package.json; no workflow calls them.
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts',
+ 'tests/e2e/ssh-ai-vault-session-history.spec.ts',
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-external-image-preview.spec.ts',
+ 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
+ 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
+ 'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
+ 'tests/e2e/ssh-skill-installation.spec.ts',
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--project',
+ 'electron-headful',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs
index 21b6cea6b0b..a03c85983d8 100644
--- a/config/scripts/runtime-serve-terminal-smoke.mjs
+++ b/config/scripts/runtime-serve-terminal-smoke.mjs
@@ -18,11 +18,14 @@
* - a paired client can list worktrees and create a terminal,
* - a command run in that terminal produces its output,
* - the server exits when asked.
+ * Optional `--browser` assertions:
+ * - create, navigate, evaluate, and screenshot through the selected host provider.
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
@@ -274,6 +277,50 @@ async function main() {
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
}
log(`server resolves ${shown.id}`)
+ if (process.argv.includes('--browser')) {
+ const status = orca(pairingCode, ['status'])
+ if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
+ throw new Error(
+ `status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
+ )
+ }
+ const fixturePath = join(userDataDir, 'browser-smoke.html')
+ writeFileSync(
+ fixturePath,
+ 'Orcad Browser Smokebrowser-ready'
+ )
+ const browserPageId = orca(pairingCode, [
+ 'tab',
+ 'create',
+ '--worktree',
+ created.id,
+ '--url',
+ 'about:blank'
+ ])?.browserPageId
+ if (!browserPageId) {
+ throw new Error('browser.tabCreate returned no browser page id')
+ }
+ const targetFlags = ['--worktree', created.id, '--page', browserPageId]
+ const targetUrl = pathToFileURL(fixturePath).href
+ const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
+ if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
+ throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
+ }
+ const evaluated = orca(pairingCode, [
+ 'eval',
+ ...targetFlags,
+ '--expression',
+ 'document.querySelector("main")?.textContent'
+ ])
+ if (evaluated?.result !== 'browser-ready') {
+ throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
+ }
+ const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
+ if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
+ throw new Error('browser.screenshot returned no PNG data')
+ }
+ log('browser navigate/evaluate/screenshot round trip OK')
+ }
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs
index d2b6adcf7d4..b6611a1aa18 100644
--- a/config/scripts/skill-sharing-release-workflow.test.mjs
+++ b/config/scripts/skill-sharing-release-workflow.test.mjs
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
})
+ it('installs the archive tool required by Electron on the Linux floor', () => {
+ const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
+ const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
+
+ expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
+ })
+
+ it('trusts only the checked-out workspace before container git operations', () => {
+ const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
+ const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
+ const restoreHarness = stepNamed(
+ linux,
+ 'Restore skill-sharing test harness from the workflow ref'
+ )
+ const safeDirectoryCommands = linux.steps
+ .filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
+ .map((step) => step.run)
+
+ expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
+ expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
+ expect(safeDirectoryCommands).toEqual([
+ 'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
+ ])
+ })
+
+ it('validates immutable tags with the current skill-sharing test harness', () => {
+ for (const jobName of [
+ 'skill-sharing-release-gate',
+ 'skill-sharing-linux-floor-release-gate'
+ ]) {
+ const restore = stepNamed(
+ workflow.jobs[jobName],
+ 'Restore skill-sharing test harness from the workflow ref'
+ )
+
+ expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
+ expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
+ expect(restore.run).toContain('skill-freshness-inventory.test.ts')
+ expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
+ }
+ })
+
it('archives bounded machine-readable evidence from every platform', () => {
for (const jobName of [
'skill-sharing-release-gate',
diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs
index 489437dd61f..07f7beb46ac 100644
--- a/config/scripts/skills-cli-package-workflow.test.mjs
+++ b/config/scripts/skills-cli-package-workflow.test.mjs
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
+ expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.cjs b/config/scripts/verify-packaged-node-pty-job-ownership.cjs
new file mode 100644
index 00000000000..5ba21cee8ec
--- /dev/null
+++ b/config/scripts/verify-packaged-node-pty-job-ownership.cjs
@@ -0,0 +1,25 @@
+const { createRequire } = require('node:module')
+const { join } = require('node:path')
+const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
+
+function loadPackagedConpty(resourcesDir) {
+ const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
+ const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
+ return loadNativeModule('conpty')
+}
+
+function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
+ const platform = options.platform ?? process.platform
+ if (platform !== 'win32') {
+ return
+ }
+
+ const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
+ assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
+ if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
+ throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
+ }
+ console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
+}
+
+module.exports = { verifyPackagedNodePtyJobOwnership }
diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs
new file mode 100644
index 00000000000..26a33cfbbd6
--- /dev/null
+++ b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs
@@ -0,0 +1,51 @@
+import { createRequire } from 'node:module'
+import { describe, expect, it, vi } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const {
+ verifyPackagedNodePtyJobOwnership
+} = require('./verify-packaged-node-pty-job-ownership.cjs')
+
+const PATCHED = {
+ dir: '../build/Release/',
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+}
+
+describe('verifyPackagedNodePtyJobOwnership', () => {
+ it('accepts the packaged patched ConPTY binding', () => {
+ expect(() =>
+ verifyPackagedNodePtyJobOwnership('resources', {
+ platform: 'win32',
+ loadNative: () => PATCHED
+ })
+ ).not.toThrow()
+ })
+
+ it('rejects a packaged upstream prebuild', () => {
+ expect(() =>
+ verifyPackagedNodePtyJobOwnership('resources', {
+ platform: 'win32',
+ loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
+ })
+ ).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
+ })
+
+ it('requires the patched source-build directory', () => {
+ expect(() =>
+ verifyPackagedNodePtyJobOwnership('resources', {
+ platform: 'win32',
+ loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
+ })
+ ).toThrow(/expected patched build\/Release/)
+ })
+
+ it('does not load Windows natives for other targets', () => {
+ const loadNative = vi.fn()
+ verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative })
+ expect(loadNative).not.toHaveBeenCalled()
+ })
+})
diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs
new file mode 100644
index 00000000000..e2e650384ec
--- /dev/null
+++ b/config/scripts/windows-process-tree-gyp-path.test.mjs
@@ -0,0 +1,42 @@
+import { execFileSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { isAbsolute, join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const projectDir = resolve(import.meta.dirname, '../..')
+const PATCH = readFileSync(
+ join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'),
+ 'utf8'
+)
+const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
+
+describe('windows-process-tree node-addon-api gyp path', () => {
+ it('stages headers without a pnpm-sensitive gyp dependency', () => {
+ expect(PATCH).not.toContain('+ "../../node-addon-api')
+ expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
+ expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
+ const buildScript = readFileSync(
+ join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
+ 'utf8'
+ )
+ expect(buildScript).toContain(
+ "for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
+ )
+ expect(buildScript).toContain("import { createRequire } from 'node:module'")
+ expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
+ expect(buildScript).toContain(
+ "createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
+ )
+ expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
+ })
+
+ it('resolves node_addon_api.gyp to a real file from the package directory', () => {
+ const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
+ cwd: PACKAGE_DIR,
+ encoding: 'utf8'
+ }).trim()
+ expect(isAbsolute(resolved)).toBe(true)
+ expect(existsSync(resolved)).toBe(true)
+ })
+})
diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs
new file mode 100644
index 00000000000..e1742a43bb0
--- /dev/null
+++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs
@@ -0,0 +1,88 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+
+const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
+
+describe('packaged Windows PTY native capability routing', () => {
+ it('runs the packaged executable immediately after the unpacked app is built', () => {
+ const job = workflow.jobs.package_windows
+ const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app')
+ const smokeIndex = job.steps.findIndex(
+ (step) => step.name === 'Smoke packaged Windows PTY native capability'
+ )
+ const smoke = job.steps[smokeIndex]
+
+ expect(job['runs-on']).toBe('windows-2022')
+ expect(smokeIndex).toBe(packageIndex + 1)
+ expect(smoke.run).toBe(
+ 'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe'
+ )
+ expect(smoke.if).toBeUndefined()
+ expect(smoke['continue-on-error']).toBeUndefined()
+ expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe(
+ 'node tests/tools/windows-pty-native-capability-smoke/run.mjs'
+ )
+ })
+
+ it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
+ const job = workflow.jobs.package_windows
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ const nodeCacheSave = job.steps.find(
+ (step) => step.name === 'Save compiled Node native modules'
+ )
+ const electronCache = job.steps.find(
+ (step) => step.name === 'Restore compiled Electron native modules'
+ )
+ const build = job.steps.find((step) => step.name === 'Build package inputs')
+ const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
+ const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
+ const verify = workflow.jobs.verify.steps.find(
+ (step) => step.name === 'Require successful checks'
+ )
+ const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
+
+ expect(install.with['native-runtime']).toBe('node')
+ expect(install.with['persist-native-cache']).toBe('false')
+ expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
+ expect(nodeCacheSave.with.key).toContain('-node-node')
+ expect(electronCache.with.key).toContain('-electron-node')
+ for (const cache of [nodeCacheSave, electronCache]) {
+ expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ }
+ expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
+ expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
+ expect(build.run).toBe('pnpm run build:release:parallel')
+ expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
+ expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
+ expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
+ expect(workflow.jobs.verify.needs).toContain('package_windows')
+ expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
+ expect(verify.run).toContain('"$PACKAGE_WINDOWS"')
+ })
+
+ it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => {
+ const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8')
+ const probe = readFileSync(
+ 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs',
+ 'utf8'
+ )
+ const source = `${driver}\n${probe}`
+
+ expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'")
+ expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i)
+ expect(source).not.toContain('maxRetries')
+ expect(source).not.toContain('retryDelay')
+ expect(source).not.toContain("from 'node:child_process'")
+ expect(source).not.toContain("require('node:child_process')")
+ expect(probe).toContain("'System32', 'wscript.exe'")
+ expect(probe).toContain('real-orca-detached-launcher.vbs')
+ expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i)
+ expect(probe).toContain('native.terminateJob(target._pty, target.pid)')
+ })
+})
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 42b3d0772d0..cec4e80cff5 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -5,6 +5,7 @@
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
+ "../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
@@ -30,8 +31,6 @@
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
- "../src/main/codex/codex-app-server-grant-bridge.ts",
- "../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
@@ -40,13 +39,16 @@
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
+ "../src/main/codex/codex-managed-trust-grant-plan.ts",
"../src/main/codex/codex-path-observation.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-state-db.ts",
+ "../src/main/codex/codex-trust-identity.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
+ "../src/main/codex/codex-trust-config-mutation-queue.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
@@ -58,10 +60,16 @@
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-settings-promotion-write-target.ts",
"../src/main/codex/config-sync-stall.ts",
+ "../src/main/codex/config-toml-atomic-write.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
+ "../src/main/codex/config-toml-hook-trust-blocks.ts",
+ "../src/main/codex/config-toml-hook-trust-edit.ts",
+ "../src/main/codex/config-toml-hook-trust-read.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
+ "../src/main/codex/config-toml-project-trust.ts",
"../src/main/codex/config-toml-runtime-owned-sections.ts",
+ "../src/main/codex/config-toml-syntax.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
@@ -75,9 +83,18 @@
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
+ "../src/main/cursor/hook-events.ts",
+ "../src/main/cursor/hook-script.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
+ "../src/main/grok/grok-hook-config.ts",
+ "../src/main/grok/grok-hook-config-cleanup.ts",
+ "../src/main/grok/grok-hook-config-file.ts",
+ "../src/main/grok/grok-hook-owners.ts",
+ "../src/main/grok/grok-hook-remote-install.ts",
+ "../src/main/grok/grok-hook-script.ts",
+ "../src/main/grok/grok-hook-symlink-cleanup-marker.ts",
"../src/main/grok/hook-service.ts",
"../src/main/grok/windows-grok-hook-script.ts",
"../src/main/devin/hook-settings.ts",
diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md
index 06be8eda659..e21aaa89228 100644
--- a/docs/STYLEGUIDE.md
+++ b/docs/STYLEGUIDE.md
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
When a control has multiple plausible primitives, use this fork:
-| You want… | Reach for | Don't use |
-| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
-| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
-| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
-| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
-| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
-| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
-| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
-| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
-| Single choice from a known list | `Select` | Custom listbox |
-| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
-| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
-| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
-| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
+| You want… | Reach for | Don't use |
+| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
+| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
+| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
+| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
+| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
+| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
+| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
+| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
+| Single choice from a known list | `Select` | Custom listbox |
+| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
+| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
+| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
+| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
If you find yourself styling around a primitive (`` to act like a `
@@ -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.44/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index b015c26449a..c9bb161fe8e 100644
--- a/docs/readme/README.fr.md
+++ b/docs/readme/README.fr.md
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
-[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 3b1992448fe..cc589be1cc5 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.44/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.46/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.44/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6e53e590463..1de67052fcb 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
+- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 9f86dc2cfa3..970fcf6c8a0 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.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 0c619ea260d..2e11413aa2a 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.44/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.46/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.44/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md
index b740982febb..3004b8888ab 100644
--- a/docs/reference/git-compatibility.md
+++ b/docs/reference/git-compatibility.md
@@ -33,13 +33,14 @@ authority.
## Current Capabilities
-| Capability | Preferred behavior | Compatibility behavior |
-| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
-| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
-| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
-| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
-| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
-| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
+| Capability | Preferred behavior | Compatibility behavior |
+| --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 |
+| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
+| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
+| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
+| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
+| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
## Why Not `simple-git`
@@ -54,7 +55,7 @@ capability problem.
## CI Contract
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
-2.49.1 binaries. This spans the core-workflow baseline, the transitional
+2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
Keep the unit tests alongside that matrix. They cover concurrent probes,
diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md
index 0e07c06ebf3..dc3fdf31da0 100644
--- a/docs/reference/headless-linux-server.md
+++ b/docs/reference/headless-linux-server.md
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
-Install the AppImage runtime dependency and Xvfb:
+Install the CLI tools, Xvfb, and the shared libraries Electron links against.
+A minimal server or container image ships none of the Electron libraries, and
+`orca serve` then fails before Electron starts:
```bash
sudo apt-get update
-sudo apt-get install -y curl file jq xvfb zlib1g-dev
+sudo apt-get install -y \
+ curl file jq xvfb zlib1g-dev ca-certificates git \
+ libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
+ libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
+ libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
+ libxcb-dri3-0 libxss1
```
-On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
-Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
+That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
+releases carried out the 64-bit `time_t` transition, which renamed six of the
+packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
+substitute the unsuffixed names:
+
+- `libgtk-3-0t64` becomes `libgtk-3-0`
+- `libatk1.0-0t64` becomes `libatk1.0-0`
+- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
+- `libasound2t64` becomes `libasound2`
+- `libcups2t64` becomes `libcups2`
+- `libatspi2.0-0t64` becomes `libatspi2.0-0`
+
+The other names are identical on every supported release. The substitution is not
+symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
+Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
+libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
+each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
+on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
+apt will not choose between two providers and exits with `E: Package 'libasound2'
+has no installation candidate`, which aborts the entire install line and leaves none
+of the libraries installed.
+
+On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
+FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
+`libfuse2` name also resolves there because nothing else provides it. FUSE is
optional: without it, use the AppImage's supported extraction path:
```bash
cd /opt/orca
./orca-linux.AppImage --appimage-extract
+sudo chmod -R a+rX /opt/orca/squashfs-root
/opt/orca/squashfs-root/AppRun serve --port 6768
```
+The `chmod` is required whenever the extraction runs as a different user than
+the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
+the extracting user, so anyone else — including a dedicated service user — cannot
+even traverse it, and the run fails before Electron starts.
+
Docker commonly has no FUSE device. Use `--appimage-extract` once or
`--appimage-extract-and-run`; neither requires a privileged container. The
extract-and-run wrapper can print extracted paths before Orca starts, so
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
+# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
+sudo chmod -R a+rX /opt/orca/squashfs-root
```
+The last line matters because the two halves of this guide combine badly without
+it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
+`orca` service user cannot read or traverse the extracted tree and the unit fails
+at startup. `chmod 755 /opt/orca` alone does not reach into it.
+
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
display `:99` when no display exists:
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
environment.
- Chromium sandbox errors: confirm the service is running as the non-root
- `orca` user and that `/opt/orca` is readable by that user.
+ `orca` user and that `/opt/orca` is readable by that user, including
+ `/opt/orca/squashfs-root` if you extracted the AppImage.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Journal shows `Another Orca instance is already running for this userData
- profile` and the unit exits `3`: another process already owns the profile, so
+profile` and the unit exits `3`: another process already owns the profile, so
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
keep it and leave the unit down), then run
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
`sudo systemctl reset-failed orca-serve.service` first.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
- `ldd squashfs-root/orca` to list any shared libraries the host is missing.
+ `ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
+ The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
+ exist prints nothing and exits cleanly, which reads as a clean result in
+ exactly the situation where you are hunting a missing library.
diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md
index 60d855c7372..a11506235fe 100644
--- a/docs/reference/linux-glibc-compatibility.md
+++ b/docs/reference/linux-glibc-compatibility.md
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
several long-stable functions into libc under brand-new symbol versions:
-| Symbol | New version | node-pty use |
-| ----------------- | ------------- | ----------------------- |
-| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
-| `openpty` | `GLIBC_2.34` | allocate the pty |
-| `forkpty` | `GLIBC_2.34` | fork the shell |
+| Symbol | New version | node-pty use |
+| ----------------- | ------------ | ----------------------- |
+| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
+| `openpty` | `GLIBC_2.34` | allocate the pty |
+| `forkpty` | `GLIBC_2.34` | fork the shell |
Electron itself (glibc 2.25) and the other bundled native modules
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
@@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
+**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
+The two gates above protect the packaged desktop app, where the binary is built and
+verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
+adds a runtime precondition
+([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
+run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
+process**, so a binary the loader refuses — or one that aborts outright — is data rather
+than this process's death, and the operator gets a sentence naming the host's libc, its
+Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
+(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
+as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
+finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.
+
+**4. Ship the binary, built from patched sources.**
+[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
+(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
+host and files it under `out/orcad/prebuilds//`, where a slot is
+`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
+because node-pty's own loader falls back to `prebuilds/-` and cannot tell
+glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
+The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
+applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
+than a first-connect error. CI runs it once per slot inside the matching container
+(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
+a hole in the matrix.
+
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md
new file mode 100644
index 00000000000..946a1ff7125
--- /dev/null
+++ b/docs/reference/macos-press-and-hold.md
@@ -0,0 +1,58 @@
+# macOS press-and-hold and key repeat
+
+macOS opens the accent picker when a key is held unless an application opts out in its preferences
+domain. That prevents held keys from repeating in terminal applications such as vim. On the first
+eligible launch, Orca writes:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
+```
+
+The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
+non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
+on the next launch.
+
+## Precedence and decision record
+
+Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
+user choice and preserved. Only an unset key receives the `false` default.
+
+The decision is stored once in
+`/macos-press-and-hold-default.json`. An `applied` or
+`kept-user-preference` decision prevents future launches from touching the domain again.
+Probe and write failures remain retryable so a transient failure does not permanently disable the
+fix.
+
+`defaults read ` is used instead of
+`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
+explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
+timeouts, and other exit statuses leave the preference alone.
+
+## Restoring the accent picker
+
+Set the preference explicitly, then restart Orca:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
+```
+
+After Orca has recorded its one-time decision, deleting the key also restores the macOS default
+without Orca recreating it:
+
+```sh
+defaults delete com.stablyai.orca ApplePressAndHoldEnabled
+```
+
+Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
+domain instead when applicable.
+
+## Reverting
+
+Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
+must also arrange to delete the key for users who ran an affected build.
+
+## Test coverage
+
+Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
+record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
+the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md
new file mode 100644
index 00000000000..bbde9829514
--- /dev/null
+++ b/docs/reference/orcad-operations.md
@@ -0,0 +1,191 @@
+# Running orcad
+
+`orcad` is the Orca runtime served from plain Node. This is the contract between it and
+whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its
+readiness payload actually proves.
+
+Design background: `docs/design/shipping-orcad.html` §00c and §04.
+
+## Two long-lived processes, not one
+
+A deployment is **orcad** plus **the terminal daemon**.
+
+| | orcad | terminal daemon |
+| ---------- | -------------------------------- | ------------------------------------- |
+| Started by | the supervisor | orcad, detached |
+| Owns | RPC, git, worktrees, persistence | every local PTY |
+| Lifetime | one supervised run | **outlives orcad** |
+| Endpoint | `ws://:` | `/daemon/daemon-v.sock` |
+
+The daemon outliving orcad is the property the whole peer model is recommended for
+(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
+restart, so a restart, an update or a rollback does not destroy running work. Everything
+below exists to keep that true.
+
+**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never
+`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's
+`KillMode=control-group` — kills the daemon too and turns every restart back into data loss.
+Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group.
+
+## Bind policy
+
+`--bind `, **default `127.0.0.1`**.
+
+Only literal IPs are accepted; hostnames are refused because DNS would decide which
+interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit
+opt-ins to network reach, and the startup log says so on every launch.
+
+The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own —
+`orca serve`'s wide default, and a startup where some device has connected before — and an
+unattended host's exposure must be exactly what the operator asked for on every launch. A
+mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is
+pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint
+nothing can reach.
+
+Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so
+loopback is the correct default and the pairing credential travels over SSH.
+
+## Data root and the instance lock
+
+The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
+
+Before the profile index or the store is touched, orcad takes `/orcad.lock`.
+It refuses to start when:
+
+| Code | Meaning |
+| -------------------------------------- | ------------------------------------------------------------- |
+| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
+| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
+| `orcad_instance_lock_held` | another live orcad owns this root |
+| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
+| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
+
+A root that is merely too permissive and that we own is tightened to `0700` rather than
+refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
+is a private root, and refusing when we could just fix it helps nobody. We refuse when the
+permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are
+not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one.
+
+A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not
+read as alive). A record belonging to a different identity is never reclaimed.
+
+**The lock scopes one role — who is the runtime.** It deliberately says nothing about the
+daemon, which lives under `/daemon` and fences its own endpoint with its own PID
+record. A lock that asked "is any process using this root" would refuse exactly the restarts
+a live daemon makes worthwhile.
+
+## Supervision
+
+### Who supervises orcad
+
+An external supervisor (systemd, launchd, a process manager). orcad conforms to it:
+
+- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published
+ after the listener is bound and the daemon verdict is in. There is no separate readiness
+ socket; the line is the signal. Set the supervisor's start timeout generously — the daemon
+ launch has its own retries and can take tens of seconds on a cold host.
+- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits
+ immediately with code 1 rather than being swallowed — a supervisor's second signal means
+ its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`,
+ the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline
+ and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
+- **Exit codes.**
+
+ | Code | Meaning | Supervisor should |
+ | ---- | ------------------------------------------------------------ | -------------------- |
+ | 0 | clean shutdown | restart per policy |
+ | 1 | startup or shutdown failure | restart with backoff |
+ | 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
+
+ 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
+ root owned by someone else is a restart-spin, not a recovery.
+
+- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
+ to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
+ its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by
+ `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see
+ [What is not covered](#what-is-not-covered).
+
+### orcad supervising the daemon
+
+- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
+ record, token and socket under `/daemon`.
+- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
+ replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
+ live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
+ to live work.
+- **Restart.** The adapter respawns the daemon on death, transparently to callers.
+- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run;
+ past that, launches are refused with `daemon_crash_loop` and terminals fail with that
+ message instead of the process forking forever. The window slides, so a repaired host
+ recovers without restarting orcad. An operator-initiated daemon restart clears it — that
+ is the deliberate "try again".
+- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login
+ session dies. An orcad daemon must survive its SSH session ending.
+- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself
+ after its adoption window; an adopted one stays resident (see Decommissioning).
+
+### Decommissioning
+
+The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with
+zero Orca processes. A daemon that has been adopted stays resident after its runtime
+disconnects — that is what makes the next start a reattach rather than a cold restore. To
+retire a host completely, stop orcad and then stop the daemon named by
+`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale.
+
+## Health
+
+The readiness payload carries a `health` object:
+
+```
+buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version
+ string cannot give, so a rollback that did not replace the file is visible
+buildVersion ORCA_VERSION
+nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match
+platform / arch / pid
+terminalDaemon:
+ state live | degraded | absent
+ ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart
+ pid the live daemon's pid, from its own PID record
+ buildVersion the build the LIVE daemon was forked from (may legitimately predate
+ this orcad after an update — reporting orcad's version for both would
+ hide exactly that)
+ entryPath / protocolVersion
+ selfTest { ok, coverage, verdict, durationMs }
+```
+
+### What the self-test proves
+
+`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the
+daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a
+real short-lived PTY spawned inside the daemon's own process**. It therefore spans both
+processes: orcad drives it, the daemon performs it, the verdict crosses the socket.
+
+- `coverage: 'pty-spawn'` — the full round trip above.
+- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without
+ spawning anything. A green verdict there covers the handshake and nothing more. It is
+ reported separately rather than folded into `ok` so nobody reads it as a PTY round trip.
+
+`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A
+daemon that answers but has fallen back to local spawning for new terminals is `degraded`,
+because those terminals die with orcad. A daemon that answered and then failed its spawn
+probe is also `degraded`, not `absent`: it still holds live sessions, and calling those
+exited would be the verdict `ssh-execution-boundary.md` forbids guessing.
+
+## What is not covered
+
+Named here so nothing reads as implemented that is not:
+
+- **A continuous health endpoint.** `health` is published once, in the readiness payload. A
+ supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same
+ `collectOrcadHealth()`; that surface does not exist yet.
+- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy
+ (plan item 5), which owns libc detection; there is no honest value to publish until then.
+- **`degradations[]`.** Plan item 2's contract, not this one.
+- **Credential administration** (list / revoke / rotate devices, expiring pending offers,
+ structured security logging) — §04, not delivered here.
+- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on
+ conflict.
+- **Reconciling `webClientUrl` with reachability** under the loopback default.
+- **State-schema rollback rules.**
+- **Daemon log rotation.** `/logs/daemon.log` grows unbounded.
diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md
index 87ed2586948..9f3d8d66472 100644
--- a/docs/reference/remote-wire-compatibility.md
+++ b/docs/reference/remote-wire-compatibility.md
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
-*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
+_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
@@ -138,3 +138,53 @@ error payload and read that instead. An old host omits it and the message match
covers them; once hosts that send it are the floor, the message match can be deleted
rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
without doing this reinstates the bug — the transport has already overwritten it.
+
+## Known hazard: clients ignore host-published failure fields on client-placed pages
+
+`RuntimeMobileSessionBrowserTab` — the browser tab a host publishes on the session-tab sync
+channel — permits `placement`, `loadError` and `certificateFailure` together. But for a tab
+whose `placement.kind` is `'client'` the engine runs in the client's own app: the failure is
+raised by the local guest webview, and the host has no view of it (`RuntimeBrowserClientPage`,
+what the registry actually publishes from, carries neither field). Clients from
+this version on therefore refuse host ownership of both records for client-placed pages
+(`web-session-tabs-sync.ts`, the `placement?.kind !== 'client'` carve-outs) — without that,
+each metadata snapshot deletes the locally recorded failure and the page's failure overlay
+disappears mid-navigation.
+
+The hazard is forward-facing and Rule 3 shaped. A host that later starts publishing
+`loadError` or `certificateFailure` for a client-placed page reaches these clients as content
+they silently drop, so the host would see no error and no effect. Publishing it has to be
+capability-gated, with the carve-out narrowed to clients that did not negotiate the
+capability. Note the cross-version harness does not exercise the session-tab sync channel, so
+nothing fails if this is forgotten — this note is the only record.
+
+A related carve-out covers `title`, `url`, `loading`, `canGoBack` and `canGoForward`
+(`resolveMirroredBrowserPageContent`), and for those the hazard is already live rather than
+forward-facing: the host does publish them, from a `RuntimeBrowserClientPage` it can only learn
+about second-hand through the client's own `browser.clientHost.pageMetadata` calls. Its copy
+therefore starts at the registry defaults (`'Browser'`, the create-time url), and while those
+publishes are failing it never leaves them.
+
+That copy is not simply behind, though, and a client must not treat it as such. When a lease
+reattaches, the host refreshes the page from the client host's own inventory
+(`runtime-browser-client-page-recovery.ts`), which reads the live guest — so it can be strictly
+fresher than a local row whose pane is unmounted and whose metadata publisher was disposed with
+it. A client that ignores the host url is relying on its own guest to re-answer on remount,
+which `ClientHostedBrowserPagePane`'s mount-time `syncNavigation` is what makes true.
+
+These five are therefore refused only by the client whose guest actually runs the page:
+`placement.browserHostClientId` is compared against this client's own host id
+(`readBrowserClientHostId`). Main stamps that id into the guest-hosting window's
+`additionalArguments` at creation, and the preload reads it back out of its own argv — the answer
+has to be there before the first snapshot is interpreted, which is earlier than any IPC handler a
+renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
+page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
+the host, which is the only reason a mirrored viewer shows anything but its first snapshot
+forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
+the carve-out no longer stands in the way of it.
+
+The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
+predicate. It is unobservable today — the host publishes neither field for a client-placed page at
+all, so a mirror has nothing to take either way. If the capability-gated publish this section
+anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
+take a failure it cannot otherwise see, and only the hosting client should refuse it.
diff --git a/docs/reference/ssh-host-key-verification.md b/docs/reference/ssh-host-key-verification.md
index a84ea7ee4ea..73955bdc7df 100644
--- a/docs/reference/ssh-host-key-verification.md
+++ b/docs/reference/ssh-host-key-verification.md
@@ -28,11 +28,11 @@ Three corrections to the first draft:
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
the first draft's motivating example was simply wrong.
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
- (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
+ (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
into `ForwardAgent`, not everyone.
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
- treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
+ treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
- OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
+ OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
-> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
+> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
-> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
+> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
-algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
+algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
friendly first-contact prompt instead of a hard failure.
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
host.** Type scoping without algorithm ordering is not a safe design.
-And when the presented type is unknown *while other types are known for this host*, that is
+And when the presented type is unknown _while other types are known for this host_, that is
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
for this host.
@@ -193,7 +193,7 @@ means nothing is known.
### D5. Recovery must not live in the failure dialog
-A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
+A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
shows the stored fingerprint so the user is choosing knowingly.
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
record cannot unblock the connect. Messages, written to avoid naming internals:
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
-> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
+> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
-`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
+`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
the system transport, so **no trust logic may key off it**, and its format must not change (see
Traps).
@@ -247,11 +247,11 @@ Traps).
## Phasing — ship the defence before the dialog
Review made the case that the riskiest part of this change is not the security model but the modal.
-Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
+Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
-launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
+launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
dialog would open on someone else's screen while the web user watches a spinner.
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
## Scope
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
-is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
+is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
`unknown`, which is the prompt-training harm D3 exists to avoid.
**Out of scope, with consequences stated:**
+
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
users get first-contact treatment for hosts they already verified.
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
its verdict:
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
- bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
+ bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
choice.
@@ -355,16 +356,16 @@ subtly wrong.**
Fixed after review:
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
- only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
+ only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
ordering — without that the guard is only half present.
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
- block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
+ block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
forbids. We consulted no entries at all.
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
Rejections are now a typed error recognised before any fallback.
-4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
+4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
same as an unreadable one, so a profile that had never connected — everyone's first run — would
have been refused, and the suite passed only because dev machines have a `known_hosts`.
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
- *desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
+ _desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index f34940143b5..4b8acd87aae 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -15,7 +15,10 @@ since removed.
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
```ts
-import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
+import {
+ readWindowsProcessTable,
+ readWindowsProcessTableFresh
+} from '../windows/windows-process-table'
```
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
@@ -31,15 +34,153 @@ survived its own teardown (#9045).
Measured on Windows 11 with 1050 processes (p50 / p95):
-| | p50 | p95 |
-| --- | --- | --- |
-| pid + ppid + name | 15.9 ms | 17.5 ms |
-| + memory + command line | 30.6 ms | 33.7 ms |
-| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
+| | p50 | p95 |
+| -------------------------------- | ------- | ------- |
+| pid + ppid + name | 15.9 ms | 17.5 ms |
+| + memory + command line | 30.6 ms | 33.7 ms |
+| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
+
+Those CIM numbers are from a 1050-process host. The scan scales with process
+count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
+**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
+match the pre-#15749 reader, so relay hosts are at parity rather than newly at
+risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x the
+706 ms figure implies. On overflow the output is truncated, the JSON fails to
+parse, and the read rejects, so a busy host loses the table rather than
+receiving a wrong one.
+
+## When a read wedges
+
+The vendored reader pushes every callback onto a module-global queue and drains
+that queue only when the request holding its `requestInProgress` latch
+completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted
+token, a worker that dies — the latch is stuck for the life of the process and
+every later call parks another closure in that queue.
+
+Two guards, and they work together:
+
+- a **3 s deadline** on each read, so a caller gets a rejection instead of a
+ promise that never settles;
+- a **sticky wedge**: once a read misses its deadline and has not called back,
+ the module refuses every further read until that read's callback fires.
+
+The wedge used to be a 30 s cooldown that let one probe through per window. That
+bounded the _rate_ of new callbacks but not the total: a permanently wedged
+reader retained one more closure every 30 s for as long as the app ran, and each
+probe also blocked its caller for the full 3 s deadline first. Gating on the
+outstanding read instead bounds retention at exactly one callback, and gives up
+nothing on recovery — a probe queued behind the latch could never have observed
+recovery anyway, whereas the stuck callback firing is the drain itself. On the
+relay's bare addon, which has no queue of its own, it is also what keeps Orca
+from re-entering `CreateToolhelp32Snapshot` while a call is still running.
+
+That last part is not just tidiness. The addon runs each read as a
+`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On
+the relay the JS queue is not there to absorb the retries, so one probe per
+window would have pinned all four default threads inside ~2 minutes — hanging
+every async `fs` and DNS call in that process, not only the process table.
+
+A wedge does **not** engage the PowerShell fallback; see the next section for
+why only absence does.
+
+## The relay has no binding, and falls back
+
+Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote
+host (`RELAY_NATIVE_DEPS` in `src/main/ssh/ssh-relay-deploy.ts`), so a Windows
+machine used as an SSH host has no `@vscode/windows-process-tree` at all. It is
+not added there on purpose. Both ways of installing it fail, and both were
+checked on a real Windows SSH host with 1486 processes:
+
+**Installing it normally rebuilds from source, and that build fails.** The
+tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
+what is already compiled inside it. On a host that _already had_ MSVC Build
+Tools 2022 installed, that build still failed:
+
+```
+error MSB8040: Spectre-mitigated libraries are required for this project.
+```
+
+That is the requirement the `binding.gyp` hunk of our patch deletes, and the
+patch cannot reach a remote host — pnpm patches do not cross SSH. Relay deploy
+would then break outright rather than degrade: `installNativeDeps` throws on
+failure, and the toolchain-skip retry is gated to Linux.
+
+**Skipping the build and using the shipped binary returns a truncated table.**
+Contrary to what this file used to claim, the published 0.8.0 tarball _does_
+contain `build/Release/windows_process_tree.node` — an MSVC build directory that
+looks accidentally published (`.obj` and `.tlog` files ship with it). It is
+N-API, so it loads on any modern Node. But it predates our patch and still has
+the `process_count < 1024` cap, so on that 1486-process host:
+
+```
+LOADED OK
+rows=1024
+selfPid=21964 present=false
+```
+
+Exactly 1024 rows, with the querying process itself among the missing. The
+self-presence guard rejects that, so the fallback engages anyway — but only on
+hosts busy enough to cross the cap. That is worse than no binding at all: it
+works on a quiet machine and fails silently under load, which is precisely the
+shape of bug that survives testing.
+
+So the constraint is not that no binary exists to ship. It is that the only
+binary available to ship is the broken one, and building the good one needs a
+toolchain the remote does not have.
+
+Instead, `windows-process-table.ts` falls back to
+`readWindowsProcessRowsWithCim` (`windows-process-table-cim-scan.ts`), the
+`Get-CimInstance` scan this module replaced. The gate is deliberately narrow:
+
+- it engages **only** when the module cannot be required, never when a loaded
+ module fails, wedges, or returns an unreadable table — a present-but-failing
+ reader must not silently start forking a shell at the caller's poll rate;
+- a fallback that also fails still rejects, so "unavailable" never degrades into
+ "nothing is running";
+- the scan applies the same self-presence guard as the native path.
+
+`src/main/ssh/relay-native-dependency-coverage.test.ts` asserts that every
+native addon reachable from the relay entry is either installed on relay hosts
+or listed there with the reason its absence is safe. That test exists because
+#15749 shipped this gap: the relay tests injected a fake module through
+`__setWindowsProcessTreeLoaderForTests`, so nothing exercised the real require.
+
+## Shipping the native reader to a relay anyway
+
+The scan is the floor, not the destination: it costs ~1.4 s and a `powershell.exe`
+where the addon costs ~57 ms. Release builds therefore compile the addon and ship
+it as an optional relay artifact.
+
+`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
+source pnpm has already patched, on a Windows runner, and refuses to run if
+any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
+hunk fails _silently_, and the relative gyp path dies at configure on Windows.
+The source is checked rather than the install trusted. It also reads the PE
+machine field of the output, because a cross-build that quietly emitted host
+arch would ship a binary the target cannot load.
+
+Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
+producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
+optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
+`MSB8020`, which is why the addon build runs before the long packaging step.
+`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
+added best-effort before it is promoted to required.
+
+`windows-process-table.ts` binds the bare addon directly rather than the package
+wrapper. That wrapper adds only a queue over `getProcessList`, and that queue is
+the wedge described above — it latches a module-global `requestInProgress` with
+no try/catch. This module already holds a single-flight and a deadline, so going
+straight to the addon drops the duplicate.
+
+The artifact is optional in `RELAY_ARTIFACTS`: hashed when present, so a relay
+carrying it never shares an immutable directory with one that does not, and
+never probed, because requiring a file only a Windows build machine can produce
+would make a correct relay read as MISSING and redeploy forever. A relay built
+on any other OS keeps using the scan.
## Why the package is patched
-`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
+`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -49,6 +190,11 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
1024 and the querying process was itself among the 27 missing. A truncated
snapshot silently hides the descendants a teardown is trying to reap — the
exact failure the native path exists to remove.
+3. **Absolute `node-addon-api` gyp path.** `require('node-addon-api').targets`
+ is cwd-relative. node-gyp on Windows evaluates it from the pnpm store
+ realpath, then loads the relative path from the `node_modules` symlink, so
+ `node_addon_api.gyp` resolves outside the repo and hourly Windows builds
+ die at configure. `node-pty` is patched the same way for the same reason.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
@@ -73,6 +219,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook
ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
+Committed private bytes have no equivalent either, and the one memory value the
+snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
+`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
+second reason `windows-process-resource-collector.ts` still runs its own
+`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
+counters in the same pass. Migrating it to the native table would cost both.
+
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
tree Orca created, so no start-time comparison is needed. Those readers should
@@ -108,7 +261,7 @@ The per-PTY job deliberately does **not** set
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
releasing the handle when the shell exits also kills whatever the user left
running, so typing `exit` in a pane reaped a `start /b` server that used to
-survive. The job exists to make an *explicit* teardown exact, not to redefine
+survive. The job exists to make an _explicit_ teardown exact, not to redefine
what a clean exit means.
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
@@ -133,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
guarantee forbids.
Once the shell exits, node-pty drops its handle record and closes the job, so a
-terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
+terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
support, not a ConPTY, or no longer tracked. It is never evidence that
processes died.
@@ -176,8 +329,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
-`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
-exempts win32, on the premise that the patch is Unix-only. That premise is now
-false, but lifting the exemption also needs `pnpm rebuild` to force a source
-build — otherwise the assertion fires and the remedy does not fix it. Left as a
-follow-up rather than changed blind.
+`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
+win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
+so the patched source build actually replaces the upstream prebuild.
diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md
index 6c93185b602..5e7e33ca0de 100644
--- a/docs/reference/windows-setup-shell.md
+++ b/docs/reference/windows-setup-shell.md
@@ -25,7 +25,7 @@ scripts:
## Why the script declares it, not the terminal preference
-`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
+`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
language a project's setup script is written in. Deriving the runner from it had two consequences:
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
diff --git a/docs/reference/worktree-scan-fingerprint.md b/docs/reference/worktree-scan-fingerprint.md
index f82d453a82c..fa0e26dac20 100644
--- a/docs/reference/worktree-scan-fingerprint.md
+++ b/docs/reference/worktree-scan-fingerprint.md
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
fan-out. The many high-frequency callers — `listTerminals` without a selector,
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
`resolveWorktreeSelector`, orchestration authority refresh — only determine
-*who* pays, not *how often*. Steady-state subprocess volume is therefore
+_who_ pays, not _how often_. Steady-state subprocess volume is therefore
`repos / 30 s`, independent of poll rate, which is exactly the observed
~1 sweep / 30.5 s.
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
bare gitdir), then records:
-| Input | External change it catches |
-| --- | --- |
-| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
-| existence of `repoPath` | main checkout deleted |
-| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
-| `/reftable` mtime + size | a tip moved under the reftable backend |
-| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
-| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
-| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
-| per entry: `locked` presence | `worktree lock` / `unlock` |
+| Input | External change it catches |
+| -------------------------------------------------- | ------------------------------------------------------------- |
+| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
+| existence of `repoPath` | main checkout deleted |
+| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
+| `/reftable` mtime + size | a tip moved under the reftable backend |
+| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
+| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
+| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
+| per entry: `locked` presence | `worktree lock` / `unlock` |
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
"per checkout" covers the main worktree and each linked worktree. Reading the
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
included) and bumps the generation, so every event-driven path still forces a
-real scan on the next read. The fingerprint only ever *extends* an entry that
+real scan on the next read. The fingerprint only ever _extends_ an entry that
the TTL alone would have refreshed.
## Freshness budget
-| Change | Before | After |
-| --- | --- | --- |
-| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
-| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
-| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
-| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
-| External `rm -rf ` | ≤ 30 s | ≤ 30 s |
-| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
-| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
-| SSH / WSL repos, folder workspaces | unchanged | unchanged |
+| Change | Before | After |
+| --------------------------------------------------------------------- | ----------------- | ----------------- |
+| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
+| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
+| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
+| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
+| External `rm -rf ` | ≤ 30 s | ≤ 30 s |
+| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
+| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
+| SSH / WSL repos, folder workspaces | unchanged | unchanged |
The two regressions are bounded by the reconciliation interval and are both
changes Orca does not make itself.
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
against a repo with 20 linked worktrees:
-| | wall per call | main-thread stall per call | worst single stall |
-| --- | --- | --- | --- |
-| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
-| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
+| | wall per call | main-thread stall per call | worst single stall |
+| ------------------- | ------------- | -------------------------- | ------------------ |
+| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
+| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
simulated minutes — and counts `git worktree list` invocations:
-| | `git worktree list` per 30 min | per hour |
-| --- | --- | --- |
-| TTL only (before) | 600 | 1,200 |
-| fingerprint gate (after) | 60 | 120 |
+| | `git worktree list` per 30 min | per hour |
+| ------------------------ | ------------------------------ | -------- |
+| TTL only (before) | 600 | 1,200 |
+| fingerprint gate (after) | 60 | 120 |
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
with genuine external activity keep rescanning at the 30 s cadence because the
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
## Rejected alternatives
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
-reduction, but it degrades *every* external-change latency to 5 min, including
+reduction, but it degrades _every_ external-change latency to 5 min, including
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
the same reduction without that regression.
diff --git a/docs/reference/wsl-command-execution.md b/docs/reference/wsl-command-execution.md
index c832ff201e5..92afcf344ad 100644
--- a/docs/reference/wsl-command-execution.md
+++ b/docs/reference/wsl-command-execution.md
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
tree and fails if the `--` form reappears.
-The `--` inside `sh -s -- ` is a *shell* argument separator and is unrelated; leave it alone.
+The `--` inside `sh -s -- ` is a _shell_ argument separator and is unrelated; leave it alone.
## 2. Machine-read output must be fenced
diff --git a/docs/reference/wsl-runner-verification.md b/docs/reference/wsl-runner-verification.md
index 39cc56871a8..59667f513de 100644
--- a/docs/reference/wsl-runner-verification.md
+++ b/docs/reference/wsl-runner-verification.md
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
## 1. Unit — runs everywhere, every PR
-| Suite | Pins |
-|---|---|
-| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
-| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
-| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
-| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
+| Suite | Pins |
+| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
+| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
+| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
+| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
## 2. Ratchets — the goalposts, enforced continuously
-| Guard | Measures |
-|---|---|
-| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
-| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
-| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
-| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
-| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
+| Guard | Measures |
+| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
+| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
+| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
+| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
+| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
-Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
+Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
## 3. Real-binary — the assertions nothing else can make
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
### Verifying a guard change
-Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
+Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md
index dead914ef74..b2da865406d 100644
--- a/docs/reference/xterm-patch-regeneration.md
+++ b/docs/reference/xterm-patch-regeneration.md
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
-The generator also builds the *unmodified* commit first and asserts that it
+The generator also builds the _unmodified_ commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 0b9ca284b95..4faa844ee4c 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
+ 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
@@ -239,12 +240,6 @@ export const electronViteConfig: UserConfig = {
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
- // Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
- // spawnSync — codex app-server trust grants need a live event loop
- // but must finish before a Codex pane launch proceeds.
- 'codex/codex-app-server-grant-entry': resolve(
- 'src/main/codex/codex-app-server-grant-entry.ts'
- ),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
@@ -285,7 +280,7 @@ export const electronViteConfig: UserConfig = {
preload: {
build: {
externalizeDeps: {
- exclude: ['@electron-toolkit/preload']
+ exclude: ['@electron-toolkit/preload', 'zod']
}
}
},
diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json
index 877a6d7e123..f3338fc3616 100644
--- a/mobile/.oxlintrc.json
+++ b/mobile/.oxlintrc.json
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/components/mobile-rich-markdown-editor-html.ts"],
- "rules": {
- "max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["src/components/CustomKeyModal.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["app/pair-scan.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/terminal/terminal-accessory-keys.ts"],
- "rules": {
- "max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["scripts/repro-worktree-startup-stream.ts"],
- "rules": {
- "max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["scripts/repro-terminal-colors.ts"],
- "rules": {
- "max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx
index e2a9b73ff7f..1ab79c50269 100644
--- a/mobile/app/connection-log.tsx
+++ b/mobile/app/connection-log.tsx
@@ -1,20 +1,35 @@
-import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
+import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { useRouter } from 'expo-router'
+import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
-import { ChevronLeft, Copy, Check } from 'lucide-react-native'
+import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
-import { connectionLogStore } from '../src/transport/connection-log-buffer'
-import { useHostClient } from '../src/transport/client-context'
+import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
+import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
- useLastConnectedAt,
+ useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
+import {
+ diagnoseConnection,
+ getReportableConnectionIncidentId
+} from '../src/diagnostics/connection-diagnostics-analysis'
+import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
+import {
+ readHydratedConnectionLog,
+ readConnectionDiagnosticsSnapshot,
+ resolveDiagnosticsHostId,
+ getDiagnosticsSubmissionState,
+ updateDiagnosticsSubmissionState,
+ type DiagnosticsSubmissionStates
+} from '../src/diagnostics/connection-diagnostics-screen-data'
+import { useHostStatusGates } from '../src/transport/host-status-gates'
+import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
+ const clientContext = useRpcClientContext()
const router = useRouter()
+ const params = useLocalSearchParams<{ hostId?: string }>()
const insets = useSafeAreaInsets()
+ const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState([])
- const [selectedId, setSelectedId] = useState(null)
- const [copied, setCopied] = useState(false)
+ const [manualSelection, setManualSelection] = useState<{
+ hostId: string
+ requestedHostId: string | undefined
+ routeKey: object
+ } | null>(null)
+ const [copiedHostId, setCopiedHostId] = useState(null)
+ const [submissionStates, setSubmissionStates] = useState({})
useEffect(() => {
let stale = false
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
return
}
setHosts(loaded)
- setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
+ const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
- const { state } = useHostClient(selected?.id)
+ const { client, state } = useHostClient(selected?.id)
+ const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
+ hostId: selected?.id,
+ client,
+ connState: state
+ })
const reconnectAttempts = useReconnectAttempt(selected?.id)
- const lastConnectedAt = useLastConnectedAt(selected?.id)
+ const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
+
+ useEffect(() => {
+ if (selectedId) {
+ void readHydratedConnectionLog(connectionLogStore, selectedId)
+ }
+ }, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
+ const diagnosis = selected
+ ? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
+ : null
+ const incidentId = selected
+ ? getReportableConnectionIncidentId({
+ endpoint: selected.endpoint,
+ state,
+ activePath,
+ pendingPath,
+ entries
+ })
+ : null
+ const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
+ const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
+ const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
+ const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
+ const snapshot = await readConnectionDiagnosticsSnapshot(
+ clientContext,
+ connectionLogStore,
+ selected.id
+ )
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
- state,
- reconnectAttempts,
- lastConnectedAt,
+ state: snapshot.state,
+ reconnectAttempts: snapshot.reconnectAttempts,
+ lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
- entries
+ desktopAppVersion,
+ entries: snapshot.entries,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath
})
await Clipboard.setStringAsync(report)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }, [selected, state, reconnectAttempts, lastConnectedAt, entries])
+ setCopiedHostId(selected.id)
+ setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
+ }, [selected, liveDesktopAppVersion, clientContext])
+
+ const sendDiagnostics = useCallback(async () => {
+ if (!selected || !submissionKey || submissionState === 'sending') {
+ return
+ }
+ const startedKey = submissionKey
+ setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
+ const appVersion = Constants.expoConfig?.version ?? 'unknown'
+ const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
+ const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
+ const snapshot = await readConnectionDiagnosticsSnapshot(
+ clientContext,
+ connectionLogStore,
+ selected.id
+ )
+ const currentIncidentId = getReportableConnectionIncidentId({
+ endpoint: selected.endpoint,
+ state: snapshot.state,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath,
+ entries: snapshot.entries
+ })
+ if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
+ setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
+ return
+ }
+ const report = buildConnectionDiagnosticsReport({
+ hostName: selected.name,
+ endpoint: selected.endpoint,
+ state: snapshot.state,
+ reconnectAttempts: snapshot.reconnectAttempts,
+ lastConnectedAt: snapshot.lastConnectedAt,
+ platform,
+ appVersion,
+ desktopAppVersion,
+ entries: snapshot.entries,
+ activePath: snapshot.activePath,
+ pendingPath: snapshot.pendingPath
+ })
+ const result = await submitConnectionDiagnostics({ report, appVersion, platform })
+ setSubmissionStates((states) =>
+ updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
+ )
+ }, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
return (
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
router.back()}>
- Connection log
+ Network diagnostics
{hosts.length > 1 && (
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
setSelectedId(host.id)}
+ onPress={() =>
+ setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
+ }
>
)}
- {copied ? 'Copied' : 'Copy diagnostics'}
+ {copied ? 'Copied' : 'Copy report'}
+ {diagnosis && (
+
+ What this suggests
+ {diagnosis.likelyCause}
+ {diagnosis.nextStep}
+ {diagnosis.reportability === 'orca-relay' && (
+ <>
+
+ Sends a size-limited redacted report including host name, endpoint, versions,
+ connection state, and events—never terminal contents or credentials.
+
+ void sendDiagnostics()}
+ disabled={submissionState === 'sending'}
+ >
+ {submissionState === 'sent' ? (
+
+ ) : (
+
+ )}
+
+ {submissionState === 'sending'
+ ? 'Sending…'
+ : submissionState === 'sent'
+ ? 'Diagnostics sent'
+ : submissionState === 'failed'
+ ? 'Retry sending'
+ : 'Send diagnostics to Orca'}
+
+
+ >
+ )}
+
+ )}
{entries.length > 0 ? (
-
+
) : (
- No connection events yet this session. Events appear as the app dials this host.
+ No connection events yet. Events appear as the app dials this host.
)}
>
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
color: colors.textSecondary
},
+ diagnosisCard: {
+ backgroundColor: colors.bgPanel,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ borderRadius: 10,
+ padding: spacing.md,
+ marginBottom: spacing.md
+ },
+ diagnosisHeading: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ marginBottom: spacing.xs
+ },
+ diagnosisText: {
+ fontSize: typography.metaSize,
+ color: colors.textPrimary,
+ lineHeight: 18
+ },
+ diagnosisNext: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary,
+ lineHeight: 18,
+ marginTop: spacing.xs
+ },
+ privacyHint: {
+ marginTop: spacing.sm,
+ fontSize: 11,
+ lineHeight: 15,
+ color: colors.textMuted
+ },
+ sendButton: {
+ marginTop: spacing.md,
+ alignSelf: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: 8,
+ backgroundColor: colors.bgRaised
+ },
+ sendButtonText: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
copyButton: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 3400fd3694b..18ebe3efe3d 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
+import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
+import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
-import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
+import { colors, spacing } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
+import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
@@ -1090,6 +1092,17 @@ export function HostScreen({
/>
)}
+ {connState !== 'connected' &&
+ !relayRecovery.pairingRejected &&
+ reconnectAttempts >= 3 &&
+ hostId ? (
+
+ router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
+ }
+ />
+ ) : null}
+
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
- topChrome: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- statusBar: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- minHeight: 34,
- paddingTop: spacing.xs,
- paddingHorizontal: spacing.lg
- },
- backButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.xs
- },
- sidebarCollapseButton: {
- width: 24,
- height: 24,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- marginLeft: spacing.xs
- },
- hostIdentity: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- minWidth: 0,
- marginRight: spacing.md
- },
- hostNameText: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- reconnectButton: {
- paddingVertical: 4,
- paddingHorizontal: spacing.sm,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- reconnectButtonText: {
- color: colors.textPrimary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- toolbar: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.md,
- gap: spacing.sm,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbar: {
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.sm,
- gap: spacing.xs,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- embeddedToolbarRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- embeddedFilterChip: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- embeddedModeButton: {
- flex: 1,
- minWidth: 0,
- height: 30,
- justifyContent: 'center',
- paddingHorizontal: spacing.xs,
- paddingVertical: 0
- },
- filterChip: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.xs,
- borderRadius: 12,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- filterChipActive: {
- borderColor: colors.textSecondary,
- backgroundColor: colors.bgRaised
- },
- filterChipText: {
- fontSize: 12,
- color: colors.textSecondary
- },
- filterChipTextActive: {
- color: colors.textPrimary
- },
- modeButton: {
- flexDirection: 'row',
- alignItems: 'center',
- flexShrink: 1,
- minWidth: 0,
- gap: 4,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- sortLabel: {
- flexShrink: 1,
- minWidth: 0,
- fontSize: 12,
- color: colors.textSecondary
- },
- toolbarSpacer: {
- flex: 1
- },
- floatingWorkspaceHeaderButton: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- marginLeft: spacing.xs
- },
- embeddedToolbarIconButton: {
- flex: 1,
- height: 28,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- toolbarIconDisabled: {
- opacity: 0.6
- },
- searchToggle: {
- padding: spacing.xs
- },
- searchBar: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- emptyText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize
- },
- list: {
- paddingBottom: spacing.lg
- },
- sectionHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.md,
- paddingBottom: spacing.xs
- },
- sectionIcon: {
- marginRight: spacing.xs
- },
- sectionRepoIcon: {
- marginRight: spacing.xs
- },
- sectionTitle: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5
- },
- sectionCount: {
- fontSize: 11,
- color: colors.textMuted,
- marginLeft: spacing.xs
- },
- separator: {
- height: 1,
- backgroundColor: colors.borderSubtle,
- marginLeft: spacing.lg + 24,
- marginRight: spacing.lg
- },
- filterModalHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: spacing.xs,
- marginBottom: spacing.md
- },
- filterModalTitle: {
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- clearFiltersText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- filterSectionLabel: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: spacing.xs,
- paddingHorizontal: spacing.xs
- },
- filterGroup: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden',
- marginBottom: spacing.md
- },
- filterRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2,
- gap: spacing.sm
- },
- filterRowText: {
- flex: 1,
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- filterSeparator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- filterRepoDot: {
- width: 8,
- height: 8,
- borderRadius: 4
- },
- confirmContent: {
- paddingBottom: spacing.lg
- },
- confirmTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: colors.textPrimary
- },
- confirmMessage: {
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- marginTop: spacing.xs,
- lineHeight: 20
- },
- confirmButtons: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- confirmBtn: {
- flex: 1,
- paddingVertical: spacing.sm + 2,
- borderRadius: 10,
- alignItems: 'center'
- },
- confirmBtnCancel: {
- backgroundColor: colors.bgPanel
- },
- confirmBtnDestructive: {
- backgroundColor: colors.statusRed
- },
- confirmBtnPressed: {
- opacity: 0.7
- },
- confirmBtnCancelText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: colors.textSecondary
- },
- confirmBtnDestructiveText: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: '#fff'
- }
-})
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 14a94513507..0d36fa96020 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -117,6 +117,10 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
+import {
+ createTerminalAccessoryRepeatController,
+ createTerminalAccessoryRepeatSender
+} from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
}
}
- async function handleAccessoryKey(input: ReturnType) {
- if (!client || !activeHandle || !canSend) {
- return
+ async function handleAccessoryKey(
+ input: ReturnType,
+ targetHandle = activeHandleRef.current,
+ isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
+ ) {
+ if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
+ return false
}
- const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
- if (accessoryCommit.kind !== 'allow-raw') {
- return
+ if (!isDeliveryTargetCurrent()) {
+ return false
}
- await sendTerminalLiveAccessoryRawBytes({
+ if (accessoryCommit.kind !== 'allow-raw') {
+ return accessoryCommit.kind === 'handled'
+ }
+ return sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
}
}
- // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
- const repeatTimeoutRef = useRef | null>(null)
- const repeatIntervalRef = useRef | null>(null)
- // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
+ const accessoryRepeatRef = useRef(
+ createTerminalAccessoryRepeatController>()
+ )
+ // Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
- if (repeatTimeoutRef.current) {
- clearTimeout(repeatTimeoutRef.current)
- repeatTimeoutRef.current = null
- }
- if (repeatIntervalRef.current) {
- clearInterval(repeatIntervalRef.current)
- repeatIntervalRef.current = null
- }
+ accessoryRepeatRef.current.stop()
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType) => {
- stopAccessoryRepeat()
- repeatTimeoutRef.current = setTimeout(() => {
- repeatIntervalRef.current = setInterval(() => {
- void handleAccessoryKeyRef.current(input)
- }, 45)
- }, 400)
+ // Why: a held repeat must not resume through a replacement client or connection.
+ const targetClient = clientRef.current
+ const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
+ const isDeliveryTargetCurrent = (targetHandle: string) =>
+ activeHandleRef.current === targetHandle &&
+ targetClient !== null &&
+ clientRef.current === targetClient &&
+ connStateRef.current === 'connected' &&
+ targetClient.getLastConnectedAt() === targetConnectedAt
+ accessoryRepeatRef.current.start(
+ input,
+ createTerminalAccessoryRepeatSender(
+ activeHandleRef.current,
+ isDeliveryTargetCurrent,
+ (nextInput, targetHandle, isTargetCurrent) =>
+ handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
+ )
+ )
},
- [stopAccessoryRepeat]
+ []
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
- stopAccessoryRepeat()
+ accessoryRepeatRef.current.cancel()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
- clearToastHideTimer,
- stopAccessoryRepeat
+ clearToastHideTimer
]
)
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
return
}
const input = createTerminalLiveAccessoryInput(key)
- void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 33a5e63807a..73785a3cce9 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -1,1362 +1,3 @@
-import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
-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 { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native'
-import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
-import {
- type AccountsSnapshot,
- type ProviderKey,
- decodeAccountsSnapshot,
- getActiveProviderRateLimits,
- getUsageBarState,
- hasActiveProviderUsage,
- hasRenderableUsage,
- UsageBar
-} from '../src/components/AccountUsage'
-import AsyncStorage from '@react-native-async-storage/async-storage'
-import { loadHostCatalog } from '../src/transport/host-store'
-import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
-import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
-import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
-import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
-import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
-import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
-import type { RpcClient } from '../src/transport/rpc-client'
-import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
-import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
-import {
- useDisconnectHostClient,
- useForceReconnect,
- useForgetHostClient,
- usePrimeHosts
-} from '../src/transport/client-context'
-import { useAllHostClients } from '../src/transport/use-all-host-clients'
-import {
- resolveHomeHostConnectionState,
- selectHomeAutoConnectHostIds
-} from '../src/transport/home-host-auto-connect'
-import { classifyConnection } from '../src/transport/connection-health'
-import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
-import {
- loadMobileOnboardingSteps,
- mobileOnboardingDestination
-} from '../src/onboarding/mobile-onboarding-plan'
-import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
-import { triggerMediumImpact } from '../src/platform/haptics'
-import { OrcaLogo } from '../src/components/OrcaLogo'
-import { MobileHostCard } from '../src/components/MobileHostCard'
-import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions'
-import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
-import { ActionSheetModal } from '../src/components/ActionSheetModal'
-import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
-import { ConfirmModal } from '../src/components/ConfirmModal'
-import {
- setCachedWorktrees,
- getCachedWorktrees,
- getProvenCachedWorktrees
-} from '../src/cache/worktree-cache'
-import {
- LAST_VISITED_WORKTREE_STORAGE_KEY,
- readLastVisitedWorktreeRecord
-} from '../src/worktree/last-visited-worktree-repo'
-import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
-import { colors, spacing, radii } from '../src/theme/mobile-theme'
-import {
- filterAvailableTaskProviders,
- normalizeVisibleTaskProviders,
- type TaskProvider
-} from '../src/tasks/mobile-task-providers'
-import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
-import { useResponsiveLayout } from '../src/layout/responsive-layout'
-import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
-import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
-import {
- isResumeTargetConfirmedMissing,
- selectHomeResumeCard,
- type HomeResumeCard
-} from '../src/worktree/home-resume-card'
-import { hostRouteWithNotice } from '../src/host-route-notice'
-import { hostNewWorktreeRoute } from '../src/host-route-action-state'
-import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
+import { MobileHomeScreen } from '../src/home/MobileHomeScreen'
-type HomeTaskSettings = {
- visibleTaskProviders?: unknown
-}
-
-type HomePreflightStatus = {
- glab?: { installed?: boolean }
-}
-
-type HomeLinearStatus = {
- connected?: boolean
-}
-
-const TASK_PROVIDER_LABELS: Record = {
- github: 'GitHub',
- gitlab: 'GitLab',
- linear: 'Linear'
-}
-
-function formatDuration(ms: number): string {
- const totalMinutes = Math.floor(ms / 60_000)
- const totalHours = Math.floor(totalMinutes / 60)
- const days = Math.floor(totalHours / 24)
- const hours = totalHours % 24
- if (days > 0) {
- return `${days}d ${hours}h`
- }
- const minutes = totalMinutes % 60
- if (totalHours > 0) {
- return `${totalHours}h ${minutes}m`
- }
- return `${totalMinutes}m`
-}
-
-// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners.
-const clientIdentities = new WeakMap()
-let nextClientIdentity = 1
-function clientKey(client: RpcClient): number {
- let id = clientIdentities.get(client)
- if (id == null) {
- id = nextClientIdentity++
- clientIdentities.set(client, id)
- }
- return id
-}
-
-function fetchStats(
- client: RpcClient,
- hostId: string,
- setStats: (
- updater: (prev: Record) => Record
- ) => void,
- disposed: () => boolean
-) {
- sendSingleFlightRequest(client, hostId, 'stats.summary')
- .then((response) => {
- if (disposed()) {
- return
- }
- if (response.ok) {
- // Keyed by host: the header totals every desktop instead of showing whoever replied last.
- setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
- }
- })
- .catch(() => {})
-}
-
-function fetchAccountsSnapshot(
- client: RpcClient,
- hostId: string,
- setSnapshots: (
- updater: (prev: Record) => Record
- ) => void,
- disposed: () => boolean
-) {
- sendSingleFlightRequest(client, hostId, 'accounts.list')
- .then((response) => {
- if (disposed()) {
- return
- }
- if (response.ok) {
- const snapshot = decodeAccountsSnapshot(response.result)
- setSnapshots((prev) => ({ ...prev, [hostId]: snapshot }))
- }
- })
- .catch(() => {})
-}
-
-function fetchTaskProviders(
- client: RpcClient,
- hostId: string,
- setProviders: (
- updater: (prev: Record) => Record
- ) => void,
- disposed: () => boolean
-) {
- Promise.all([
- sendSingleFlightRequest(client, hostId, 'settings.get'),
- sendSingleFlightRequest(client, hostId, 'preflight.check'),
- sendSingleFlightRequest(client, hostId, 'linear.status')
- ])
- .then(([settingsResponse, preflightResponse, linearResponse]) => {
- if (disposed()) {
- return
- }
- const settings = settingsResponse.ok
- ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
- {}) as HomeTaskSettings)
- : {}
- const preflight = preflightResponse.ok
- ? (preflightResponse.result as HomePreflightStatus)
- : null
- const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null
- const providers = filterAvailableTaskProviders(
- normalizeVisibleTaskProviders(settings.visibleTaskProviders),
- {
- gitlabInstalled: preflight?.glab?.installed === true,
- linearConnected: linear?.connected === true
- }
- )
- setProviders((prev) => ({ ...prev, [hostId]: providers }))
- })
- .catch(() => {
- if (disposed()) {
- return
- }
- setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] }))
- })
-}
-
-// Why: hash repo name to a stable color, matching the host detail page's dots.
-const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
-function repoColor(name: string): string {
- let hash = 0
- for (let i = 0; i < name.length; i++) {
- hash = (hash * 31 + name.charCodeAt(i)) | 0
- }
- return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length]
-}
-
-export default function HomeScreen() {
- const router = useRouter()
- const openMobileHostEdit = useOpenMobileHostEdit()
- const openMobileTasks = useOpenMobileTasks()
- const openMobileSession = useOpenMobileSession()
- const openMobileAccounts = useOpenMobileAccounts()
- const insets = useSafeAreaInsets()
- // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
- const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
- const [hostCatalog, setHostCatalog] = useState([])
- const [actionTarget, setActionTarget] = useState(null)
- const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
- const [hostStates, setHostStates] = useState>({})
- const [hostAttempts, setHostAttempts] = useState>({})
- const [hostLastConnected, setHostLastConnected] = useState>({})
- const [statsByHost, setStatsByHost] = useState>({})
- const [worktreeInfo, setWorktreeInfo] = useState>({})
- const [accountsByHost, setAccountsByHost] = useState>({})
- const [taskProvidersByHost, setTaskProvidersByHost] = useState>({})
- const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
- null
- )
- // Why: focus can fire repeatedly while an async gate is pending; one probe per
- // mount avoids duplicate storage/permission reads and competing navigation.
- const onboardingOptInCheckedRef = useRef(false)
-
- // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
- const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
- const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
- // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
- const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
- const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
- const allClients = useAllHostClients(hostIds, {
- autoConnectHostIds,
- closeUnusedOnRelease: true
- })
- const hostPaths = useMemo(
- () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
- [allClients]
- )
- const hostPendingPaths = useMemo(
- () => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
- [allClients]
- )
- const hostPairingRejected = useMemo(
- () =>
- Object.fromEntries(
- allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
- ),
- [allClients]
- )
- const disconnectHostClient = useDisconnectHostClient()
- const forgetHostClient = useForgetHostClient()
- const forceReconnectHost = useForceReconnect()
- const primeHosts = usePrimeHosts()
- // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
- useEffect(() => {
- if (hosts.length > 0) {
- primeHosts(hosts)
- }
- }, [hosts, primeHosts])
- const allClientsRef = useRef>([])
- // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients.
- allClientsRef.current = allClients.map((entry) => ({
- hostId: entry.hostId,
- client: entry.client
- }))
-
- // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty.
- const hydratedRef = useRef(false)
- useEffect(() => {
- if (hydratedRef.current) {
- return
- }
- hydratedRef.current = true
- let cancelled = false
- void loadHomeSnapshot().then((snap) => {
- if (cancelled || !snap) {
- return
- }
- setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo))
- setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost))
- for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
- const wt = info.lastActiveWorktree
- if (wt) {
- // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object.
- setCachedWorktrees(hostId, [wt])
- }
- }
- })
- return () => {
- cancelled = true
- }
- }, [])
-
- // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes).
- useEffect(() => {
- if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
- return
- }
- saveHomeSnapshot({
- worktreeInfo,
- accountsByHost,
- savedAt: Date.now()
- })
- }, [worktreeInfo, accountsByHost])
-
- useFocusEffect(
- useCallback(() => {
- let stale = false
- void loadHostCatalog().then(async (catalog) => {
- if (stale) {
- return
- }
- setHostCatalog(catalog)
- if (catalog.length === 0 || onboardingOptInCheckedRef.current) {
- return
- }
- onboardingOptInCheckedRef.current = true
- const onboardingSteps = await loadMobileOnboardingSteps()
- if (stale) {
- return
- }
- if (onboardingSteps.length > 0) {
- router.replace(mobileOnboardingDestination(onboardingSteps))
- }
- })
- void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
- if (stale) {
- return
- }
- // Why the validating reader: this record becomes the Resume card's navigation target,
- // so a malformed or older-shaped payload must read as no history, not a broken route.
- setLastVisited(readLastVisitedWorktreeRecord(raw))
- })
- for (const entry of allClientsRef.current) {
- if (entry.client.getState() === 'connected') {
- fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
- void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
- fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
- fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
- }
- }
- return () => {
- stale = true
- }
- }, [router])
- )
-
- const sortedHosts = useMemo(
- () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
- [hosts]
- )
- const sortedHostCatalog = useMemo(
- () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected),
- [hostCatalog]
- )
-
- // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
- useEffect(() => {
- setHostAttempts((prev) => {
- const next: Record = { ...prev }
- let changed = false
- for (const entry of allClients) {
- const a = entry.client.getReconnectAttempt()
- if (next[entry.hostId] !== a) {
- next[entry.hostId] = a
- changed = true
- }
- }
- return changed ? next : prev
- })
- setHostLastConnected((prev) => {
- const next: Record = { ...prev }
- let changed = false
- for (const entry of allClients) {
- const t = entry.client.getLastConnectedAt()
- if (next[entry.hostId] !== t) {
- next[entry.hostId] = t
- changed = true
- }
- }
- return changed ? next : prev
- })
- setHostStates((prev) => {
- const next: Record = { ...prev }
- let changed = false
- const liveIds = new Set(allClients.map((e) => e.hostId))
- for (const entry of allClients) {
- if (next[entry.hostId] !== entry.state) {
- next[entry.hostId] = entry.state
- changed = true
- }
- }
- // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
- for (const host of hostCatalog) {
- if (liveIds.has(host.id)) {
- continue
- }
- if (host.credentialStatus === 'missing') {
- if (next[host.id] !== 'auth-failed') {
- next[host.id] = 'auth-failed'
- changed = true
- }
- continue
- }
- if (host.credentialStatus === 'temporarily-unavailable') {
- if (next[host.id] !== 'disconnected') {
- next[host.id] = 'disconnected'
- changed = true
- }
- continue
- }
- const prevState = next[host.id]
- if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
- next[host.id] = 'disconnected'
- changed = true
- }
- }
- // Drop entries for hosts we no longer track at all.
- for (const id of Object.keys(next)) {
- if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) {
- delete next[id]
- changed = true
- }
- }
- return changed ? next : prev
- })
- }, [allClients, hostCatalog])
-
- // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
- // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
- // returned disposer owns every handle allocated here.
- const wireHostSubscriptions = (entry: {
- hostId: string
- client: RpcClient
- state: ConnectionState
- }) => {
- let unsubNotif: (() => void) | null = null
- let unsubAccounts: (() => void) | null = null
- const refetchGate = createHostConnectRefetchGate()
- const wireUp = (state: ConnectionState) => {
- const reconnected = refetchGate.observe(state)
- if (state === 'connected') {
- if (!unsubNotif) {
- unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
- }
- if (!unsubAccounts) {
- unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
- if (!payload || typeof payload !== 'object') {
- return
- }
- const evt = payload as { type?: string; snapshot?: unknown }
- if (evt.type === 'ready' || evt.type === 'snapshot') {
- try {
- const snapshot = decodeAccountsSnapshot(evt.snapshot)
- setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
- } catch {
- // Keep the last proven snapshot; malformed remote data must
- // not enter render state or crash the home host cards.
- }
- }
- })
- }
- // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
- // snapshot on every reconnect — a one-shot latch left the card on stale data forever.
- if (reconnected) {
- fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
- void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
- fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
- }
- } else {
- if (unsubNotif) {
- unsubNotif()
- unsubNotif = null
- }
- if (unsubAccounts) {
- unsubAccounts()
- unsubAccounts = null
- }
- }
- }
- wireUp(entry.state)
- const unsubState = entry.client.onStateChange(wireUp)
- return () => {
- unsubState()
- unsubNotif?.()
- unsubAccounts?.()
- }
- }
-
- // Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
- useEffect(() => {
- const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
- return () => {
- for (const c of cleanups) {
- c()
- }
- }
- // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render.
- }, [
- allClients
- .map((e) => `${e.hostId}:${clientKey(e.client)}`)
- .sort()
- .join(',')
- ])
-
- // Why: the card renders from cached/snapshot data the moment a candidate exists — see
- // selectHomeResumeCard for why its slot must not wait for the host to connect.
- const resumeCard = useMemo(
- () =>
- selectHomeResumeCard({
- hosts: sortedHosts,
- hostStates,
- worktreeInfo,
- lastVisited,
- cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
- }),
- [sortedHosts, hostStates, worktreeInfo, lastVisited]
- )
-
- // Why: the card is drawn from a snapshot that can name a workspace the desktop has since
- // deleted. When the host has proven otherwise, open its workspace list rather than a session
- // screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes
- // through and the session screen bounces once the host answers (F7).
- const openResume = useCallback(
- (card: HomeResumeCard) => {
- if (
- isResumeTargetConfirmedMissing(
- card,
- getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
- )
- ) {
- router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
- return
- }
- openMobileSession({
- hostId: card.hostId,
- worktreeId: card.worktree.worktreeId,
- name: card.worktree.displayName || card.worktree.repo
- })
- },
- [openMobileSession, router]
- )
-
- // Why: only show Account usage for connected hosts; stale cached usage would imply live data.
- const accountsHosts = useMemo(() => {
- const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
- for (const host of sortedHosts) {
- if (hostStates[host.id] !== 'connected') {
- continue
- }
- const snap = accountsByHost[host.id]
- if (!snap) {
- continue
- }
- // Why: also show hosts whose only usage is the system-default login, else those users see no usage section.
- if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
- items.push({ host, snapshot: snap })
- }
- }
- return items
- }, [sortedHosts, hostStates, accountsByHost])
-
- const connectedHosts = useMemo(
- () => sortedHosts.filter((host) => hostStates[host.id] === 'connected'),
- [sortedHosts, hostStates]
- )
- const primaryConnectedHost = connectedHosts[0] ?? null
- const primaryTaskProviders = primaryConnectedHost
- ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github'])
- : []
- const openTasks = useCallback(
- (provider?: TaskProvider) => {
- if (!primaryConnectedHost) {
- return
- }
- openMobileTasks(primaryConnectedHost.id, provider)
- },
- [openMobileTasks, primaryConnectedHost]
- )
- const renderTaskHomeCard = () => (
- [
- styles.taskHomeCard,
- !primaryConnectedHost && styles.cardDisabled,
- pressed && styles.hostCardPressed
- ]}
- onPress={() => {
- openTasks()
- }}
- >
-
-
-
-
- Tasks
-
- {primaryTaskProviders.length > 0
- ? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
- : 'No task sources connected'}
-
-
-
- TASK_PROVIDER_LABELS[provider])
- .join(', ')}
- >
- {primaryTaskProviders.map((provider) => (
- [
- styles.taskHomeProviderButton,
- pressed && styles.taskHomeProviderButtonPressed
- ]}
- onPress={(event) => {
- event.stopPropagation()
- openTasks(provider)
- }}
- >
-
-
- ))}
-
-
-
-
- )
-
- async function handleRemove() {
- if (!confirmRemove) {
- return
- }
- const hostToRemove = confirmRemove
- try {
- await removeHostAndCloseClient(hostToRemove.id, forgetHostClient)
- setConfirmRemove(null)
- setHostCatalog(await loadHostCatalog())
- } catch {
- // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
- setConfirmRemove(hostToRemove)
- Alert.alert('Could not remove host', 'Please try again.')
- }
- }
-
- return (
-
- {/* ─── Top bar ─── */}
-
-
-
-
-
- Orca
-
- [styles.iconButton, pressed && styles.iconButtonPressed]}
- onPress={() => router.push('/settings')}
- >
-
-
-
-
- {hostCatalog.length === 0 ? (
- /* ─── Empty state: onboarding ─── */
-
-
- Connect your desktop
-
- Pair with Orca on your computer to check on your agents, jump into any terminal, and
- drive work from your phone.
-
- router.push('/pair-scan')}>
-
- Pair Desktop
-
-
-
-
- How it works
- {ONBOARDING_STEPS.map((step, i) => (
- 0 && styles.stepRowBorder]}>
-
- {i + 1}
-
-
- {step.title}
- {step.desc}
-
-
- ))}
-
-
- ) : (
- /* ─── Populated state ─── */
- h.id}
- // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
- contentContainerStyle={[
- styles.list,
- { paddingBottom: spacing.xl + insets.bottom },
- isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
- ]}
- ListHeaderComponent={
-
-
- Welcome back
-
-
- {stats && (
-
-
-
- {stats.totalAgentsSpawned.toLocaleString()}
-
- Agents spawned
-
-
- {formatDuration(stats.totalAgentTimeMs)}
- Agent time
-
-
- {stats.totalPRsCreated.toLocaleString()}
- PRs created
-
-
- )}
-
- Desktops
-
- }
- ItemSeparatorComponent={CardGap}
- renderItem={({ item }) => {
- const state = resolveHomeHostConnectionState(
- item.id,
- hostStates[item.id],
- autoConnectHostIds
- )
- const attempts = hostAttempts[item.id] ?? 0
- const lastConnectedAt = hostLastConnected[item.id] ?? null
- const verdict = classifyConnection({
- state,
- reconnectAttempts: attempts,
- lastConnectedAt,
- endpoint: item.endpoint,
- pendingPath: hostPendingPaths[item.id] ?? null,
- pairingRejected: hostPairingRejected[item.id] ?? false
- })
- return (
- {
- if (item.credentialStatus === 'missing') {
- router.push('/pair-scan')
- } else if (item.credentialStatus === 'temporarily-unavailable') {
- void loadHostCatalog()
- .then(setHostCatalog)
- .catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
- } else {
- router.push(`/h/${item.id}`)
- }
- }}
- onLongPress={() => {
- triggerMediumImpact()
- if (item.profile) {
- setActionTarget(item.profile)
- } else {
- setConfirmRemove(item)
- }
- }}
- onOpenActions={() => {
- if (item.profile) {
- setActionTarget(item.profile)
- } else {
- setConfirmRemove(item)
- }
- }}
- />
- )
- }}
- ListFooterComponent={
-
- {/* ─── Resume card ─── */}
- {resumeCard ? (
- <>
- Resume
- [
- styles.resumeCard,
- !resumeCard.actionable && styles.cardDisabled,
- pressed && styles.hostCardPressed
- ]}
- onPress={() => openResume(resumeCard)}
- >
-
-
-
-
-
- {resumeCard.worktree.displayName}
-
-
-
-
- {resumeCard.worktree.repo}
- {' · '}
- {resumeCard.worktree.branch}
-
-
-
-
-
- >
- ) : null}
- Tasks
- {renderTaskHomeCard()}
-
- {/* ─── Quick actions ─── */}
- router.push('/pair-scan')}
- onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
- />
-
- {/* ─── Account usage ─── */}
- {accountsHosts.length > 0 ? (
- <>
-
- Account usage
-
- {accountsHosts.map(({ host, snapshot }) => {
- const claudeActiveId = snapshot.claude.activeAccountId
- const claudeActive =
- snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null
- const codexActiveId = snapshot.codex.activeAccountId
- const codexActive =
- snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null
- const showHostName = accountsHosts.length > 1
- return (
- [
- styles.accountsCard,
- pressed && styles.hostCardPressed
- ]}
- onPress={() => openMobileAccounts(host.id)}
- >
- {showHostName ? (
-
- {host.name}
-
- ) : null}
- {(['claude', 'codex'] as ProviderKey[]).map((provider) => {
- const active = provider === 'claude' ? claudeActive : codexActive
- const accounts =
- provider === 'claude'
- ? snapshot.claude.accounts
- : snapshot.codex.accounts
- const limits = getActiveProviderRateLimits(snapshot, provider)
- // Why: with no managed accounts, still render the row when the active target has live usage data.
- if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
- return null
- }
- const sessionBar = getUsageBarState(limits, 'session')
- const weeklyBar = getUsageBarState(limits, 'weekly')
- return (
-
-
- {provider === 'claude' ? (
-
- ) : (
-
- )}
-
-
-
- {active?.email ?? 'System default'}
-
-
-
-
-
-
-
- )
- })}
-
- )
- })}
- >
- ) : null}
-
- }
- />
- )}
-
- {/* ─── Action sheets (shared by both states) ─── */}
- setActionTarget(null),
- onReconnect: (hostId) => void forceReconnectHost(hostId),
- onDisconnect: disconnectHostClient,
- onEdit: openMobileHostEdit,
- onRemove: (host) => setConfirmRemove(host)
- })}
- onClose={() => setActionTarget(null)}
- />
-
- void handleRemove()}
- onCancel={() => setConfirmRemove(null)}
- />
-
- )
-}
-
-function CardGap() {
- return
-}
-
-const ONBOARDING_STEPS = [
- {
- title: 'Open Orca desktop',
- desc: 'Go to Settings → Mobile and generate a pairing QR code.'
- },
- {
- title: 'Scan the code',
- desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.'
- },
- {
- title: "You're connected",
- desc: 'Your desktop will appear here. Everything is encrypted end-to-end.'
- }
-]
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
-
- /* ─── Top bar ─── */
- topBar: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.sm,
- paddingBottom: spacing.md
- },
- brandLockup: {
- flexDirection: 'row',
- alignItems: 'center',
- minWidth: 0
- },
- logoMark: {
- marginRight: spacing.sm
- },
- brandName: {
- color: colors.textPrimary,
- fontSize: 17,
- fontWeight: '700'
- },
- iconButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center'
- },
- iconButtonPressed: {
- backgroundColor: colors.bgRaised
- },
-
- /* ─── Hero / greeting ─── */
- hero: {
- paddingTop: spacing.xs,
- paddingBottom: spacing.md
- },
- heroTitle: {
- color: colors.textPrimary,
- fontSize: 24,
- fontWeight: '800',
- letterSpacing: -0.3
- },
-
- /* ─── Stat cards ─── */
- statsRow: {
- flexDirection: 'row',
- gap: 10,
- marginBottom: spacing.lg
- },
- statCard: {
- flex: 1,
- backgroundColor: 'rgba(26,26,26,0.6)',
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: 10,
- paddingVertical: 8,
- paddingHorizontal: spacing.md
- },
- statValue: {
- color: colors.textPrimary,
- fontSize: 18,
- fontWeight: '700',
- letterSpacing: -0.3
- },
- statLabel: {
- color: colors.textMuted,
- fontSize: 11,
- fontWeight: '500',
- marginTop: 2
- },
-
- /* ─── Section heading ─── */
- sectionHeading: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.6,
- marginBottom: spacing.sm,
- paddingHorizontal: spacing.xs
- },
- sectionHeadingTightTop: {
- marginTop: spacing.lg
- },
-
- /* ─── List ─── */
- list: {
- paddingHorizontal: spacing.lg,
- paddingBottom: spacing.xl
- },
- cardGap: {
- height: spacing.sm
- },
-
- /* ─── Host cards ─── */
- hostCardPressed: {
- backgroundColor: colors.bgRaised
- },
-
- /* ─── Resume card ─── */
- resumeCard: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.card,
- paddingLeft: spacing.md,
- paddingRight: spacing.md,
- paddingVertical: 12
- },
- resumeIcon: {
- width: 46,
- height: 46,
- borderRadius: 13,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 14
- },
- resumeMain: {
- flex: 1,
- minWidth: 0
- },
- resumeTitle: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary
- },
- resumeSub: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- marginTop: 3
- },
- repoDot: {
- width: 7,
- height: 7,
- borderRadius: 3.5
- },
- resumeSubText: {
- fontSize: 12,
- color: colors.textSecondary,
- flex: 1
- },
-
- /* ─── Tasks card ─── */
- taskHomeCard: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.card,
- minHeight: 72,
- paddingLeft: spacing.md,
- paddingRight: spacing.md,
- paddingVertical: 12
- },
- cardDisabled: {
- opacity: 0.45
- },
- taskHomeIcon: {
- width: 46,
- height: 46,
- borderRadius: 13,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 14
- },
- taskHomeMain: {
- flex: 1,
- minWidth: 0
- },
- taskHomeTitle: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary
- },
- taskHomeSubtitle: {
- fontSize: 12,
- color: colors.textSecondary,
- marginTop: 3
- },
- taskHomeTrailing: {
- flexDirection: 'row',
- alignItems: 'center',
- flexShrink: 0,
- marginLeft: spacing.sm
- },
- taskHomeProviderRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'flex-end',
- gap: 2
- },
- taskHomeProviderButton: {
- width: 34,
- height: 34,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- taskHomeProviderButtonPressed: {
- backgroundColor: colors.bgRaised
- },
-
- /* ─── Account usage ─── */
- accountsCard: {
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.card,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm + 2,
- gap: spacing.sm,
- marginBottom: spacing.sm
- },
- accountsHostLabel: {
- fontSize: 11,
- color: colors.textMuted,
- fontWeight: '500',
- textTransform: 'uppercase',
- letterSpacing: 0.4
- },
- accountsRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm + 2
- },
- accountsIcon: {
- width: 32,
- height: 32,
- borderRadius: 9,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center'
- },
- accountsInfo: {
- flex: 1,
- minWidth: 0,
- gap: 2
- },
- accountsEmail: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary
- },
- accountsBars: {
- flexDirection: 'row',
- gap: spacing.md,
- marginTop: 4
- },
-
- /* ─── Empty state ─── */
- emptyContainer: {
- flex: 1
- },
- emptyGreeting: {
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.md,
- paddingBottom: spacing.sm
- },
- emptyHero: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- paddingHorizontal: 32,
- paddingBottom: 40
- },
- emptyTitle: {
- fontSize: 22,
- fontWeight: '700',
- color: colors.textPrimary,
- textAlign: 'center',
- marginBottom: 10
- },
- emptyBody: {
- fontSize: 15,
- color: colors.textSecondary,
- textAlign: 'center',
- lineHeight: 22,
- marginBottom: 32
- },
- primaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 10,
- backgroundColor: colors.textPrimary,
- paddingHorizontal: 28,
- paddingVertical: 14,
- borderRadius: radii.card
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: 15,
- fontWeight: '700'
- },
-
- /* ─── Onboarding steps ─── */
- stepsSection: {
- paddingHorizontal: spacing.xl
- },
- stepRow: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- gap: 14,
- paddingVertical: spacing.lg
- },
- stepRowBorder: {
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle
- },
- stepNum: {
- width: 28,
- height: 28,
- borderRadius: 8,
- backgroundColor: 'rgba(255,255,255,0.04)',
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: 1
- },
- stepNumText: {
- fontSize: 12,
- fontWeight: '700',
- color: colors.textSecondary
- },
- stepText: {
- flex: 1
- },
- stepTitle: {
- fontSize: 14,
- fontWeight: '600',
- color: colors.textPrimary,
- marginBottom: 3
- },
- stepDesc: {
- fontSize: 12,
- color: colors.textMuted,
- lineHeight: 17
- }
-})
+export default MobileHomeScreen
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index c78d7a907df..61e99f19f2a 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
- StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
-import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
+import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
+import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- padding: spacing.lg
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: spacing.sm
- },
- steps: {
- gap: spacing.sm,
- marginBottom: spacing.lg,
- marginLeft: 7
- },
- step: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- stepBadge: {
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center'
- },
- stepNumber: {
- fontSize: 12,
- fontWeight: '700',
- color: colors.textSecondary
- },
- stepText: {
- fontSize: typography.bodySize,
- color: colors.textSecondary
- },
- cameraWrap: {
- flex: 1,
- borderRadius: radii.camera,
- overflow: 'hidden'
- },
- // Why: holds the layout slot while the camera is unmounted during
- // paste, so the bottom action button doesn't snap up to fill the
- // empty space.
- cameraPlaceholder: {
- flex: 1,
- backgroundColor: colors.bgPanel,
- borderRadius: radii.camera
- },
- camera: {
- ...StyleSheet.absoluteFillObject
- },
- reticle: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center'
- },
- reticleFrame: {
- position: 'relative'
- },
- corner: {
- position: 'absolute',
- width: 28,
- height: 28,
- borderColor: 'rgba(255,255,255,0.7)'
- },
- cornerTL: {
- top: 0,
- left: 0,
- borderTopWidth: 2.5,
- borderLeftWidth: 2.5,
- borderTopLeftRadius: 6
- },
- cornerTR: {
- top: 0,
- right: 0,
- borderTopWidth: 2.5,
- borderRightWidth: 2.5,
- borderTopRightRadius: 6
- },
- cornerBL: {
- bottom: 0,
- left: 0,
- borderBottomWidth: 2.5,
- borderLeftWidth: 2.5,
- borderBottomLeftRadius: 6
- },
- cornerBR: {
- bottom: 0,
- right: 0,
- borderBottomWidth: 2.5,
- borderRightWidth: 2.5,
- borderBottomRightRadius: 6
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- fontSize: typography.titleSize,
- fontWeight: '600',
- color: colors.textPrimary,
- marginBottom: spacing.sm
- },
- subtitle: {
- maxWidth: 310,
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- connectingText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- marginTop: spacing.lg
- },
- logSlot: {
- width: '100%',
- marginTop: spacing.lg,
- paddingHorizontal: spacing.sm
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- primaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- backgroundColor: colors.textPrimary,
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm + 2,
- borderRadius: radii.button
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- pasteButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- marginTop: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- pasteButtonPressed: {
- opacity: 0.6
- },
- pasteButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- },
- errorActions: {
- alignItems: 'center',
- gap: spacing.sm
- },
- secondaryButton: {
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- secondaryButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- }
-})
diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx
index 5ae53e0084c..c4313e88fcc 100644
--- a/mobile/app/troubleshoot.tsx
+++ b/mobile/app/troubleshoot.tsx
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
onPress={() => router.push('/connection-log')}
>
- View connection log
+ View network diagnostics
{checks.length > 0 && (
diff --git a/mobile/patches/react-native@0.83.9.patch b/mobile/patches/react-native@0.83.9.patch
index 102b052746b..9bda1b252fa 100644
--- a/mobile/patches/react-native@0.83.9.patch
+++ b/mobile/patches/react-native@0.83.9.patch
@@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf
AttributedString::Range selectionRange;
// ScrollView-like metrics
Size contentSize;
+diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
+index 79ee7ffe43..956f189a1c 100644
+--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
++++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
+@@ -136,6 +136,7 @@ using namespace facebook::react;
+ - (void)prepareForRecycle
+ {
+ [super prepareForRecycle];
+ _textView.state = nullptr;
++ _textView.layoutMetrics = EmptyLayoutMetrics;
+ _accessibilityProvider = nil;
+ }
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 56e64e1d442..01092f2acc9 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
react-native@0.83.9:
- hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
+ hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
path: patches/react-native@0.83.9.patch
importers:
diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts
new file mode 100644
index 00000000000..50e4e153b54
--- /dev/null
+++ b/mobile/scripts/repro-terminal-color-output.ts
@@ -0,0 +1,60 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type TerminalColorSnapshot = {
+ label: string
+ handle: string
+ cols: number | null
+ rows: number | null
+ serialized: string
+ lines: string
+}
+
+function countMatches(value: string, pattern: RegExp): number {
+ return value.match(pattern)?.length ?? 0
+}
+
+export function summarizeTerminalColorSnapshot(
+ snapshot: TerminalColorSnapshot
+): Record {
+ const data = snapshot.serialized
+ return {
+ label: snapshot.label,
+ handle: snapshot.handle,
+ cols: snapshot.cols,
+ rows: snapshot.rows,
+ serializedBytes: Buffer.byteLength(data),
+ sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
+ sgrColor: countMatches(
+ data,
+ new RegExp(
+ `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
+ 'g'
+ )
+ ),
+ sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
+ altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
+ containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
+ containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
+ }
+}
+
+export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
+ const dir = join(process.cwd(), 'terminal-color-repro')
+ mkdirSync(dir, { recursive: true })
+ for (const snapshot of snapshots) {
+ const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
+ writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
+ writeFileSync(
+ join(dir, `${base}.escaped.txt`),
+ JSON.stringify(snapshot.serialized || snapshot.lines)
+ )
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts
index 3c21df64176..e6c302652a0 100644
--- a/mobile/scripts/repro-terminal-colors.ts
+++ b/mobile/scripts/repro-terminal-colors.ts
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]
*/
-import { mkdirSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveTerminalColorSnapshots,
+ summarizeTerminalColorSnapshot,
+ type TerminalColorSnapshot
+} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
-type Snapshot = {
- label: string
- handle: string
- cols: number | null
- rows: number | null
- serialized: string
- lines: string
-}
-
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise {
+async function captureSnapshot(
+ ws: WebSocket,
+ label: string,
+ handle: string
+): Promise {
const id = nextId()
- const snapshot = await new Promise((resolve, reject) => {
+ const snapshot = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
-function countMatches(value: string, pattern: RegExp): number {
- return value.match(pattern)?.length ?? 0
-}
-
-function summarize(snapshot: Snapshot): Record {
- const data = snapshot.serialized
- return {
- label: snapshot.label,
- handle: snapshot.handle,
- cols: snapshot.cols,
- rows: snapshot.rows,
- serializedBytes: Buffer.byteLength(data),
- sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
- sgrColor: countMatches(
- data,
- new RegExp(
- `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
- 'g'
- )
- ),
- sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
- altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
- containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
- containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
- }
-}
-
-function saveSnapshots(snapshots: Snapshot[]): string {
- const dir = join(process.cwd(), 'terminal-color-repro')
- mkdirSync(dir, { recursive: true })
- for (const snapshot of snapshots) {
- const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
- writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
- writeFileSync(
- join(dir, `${base}.escaped.txt`),
- JSON.stringify(snapshot.serialized || snapshot.lines)
- )
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
- const dir = saveSnapshots(snapshots)
+ const dir = saveTerminalColorSnapshots(snapshots)
- console.table(snapshots.map(summarize))
+ console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
- if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
+ if (
+ summarizeTerminalColorSnapshot(firstA).sgrColor !==
+ summarizeTerminalColorSnapshot(secondA).sgrColor
+ ) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts
new file mode 100644
index 00000000000..83e127a3fa6
--- /dev/null
+++ b/mobile/scripts/repro-worktree-startup-output.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type StartupTerminalCapture = {
+ handle: string
+ title: string | null
+ scrollback: Record | null
+ chunks: string[]
+}
+
+function stripAnsi(value: string): string {
+ return (
+ value
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
+ .replace(/\r/g, '\n')
+ )
+}
+
+function normalizePreview(value: string): string {
+ return stripAnsi(value)
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(0, 8)
+ .join('\n')
+}
+
+export function summarizeStartupCapture(capture: StartupTerminalCapture): Record {
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ const live = capture.chunks.join('')
+ const serializedPreview = normalizePreview(serialized)
+ const livePreview = normalizePreview(live)
+ return {
+ handle: capture.handle,
+ title: capture.title,
+ cols: capture.scrollback?.cols ?? null,
+ rows: capture.scrollback?.rows ?? null,
+ serializedBytes: Buffer.byteLength(serialized),
+ liveBytes: Buffer.byteLength(live),
+ liveChunks: capture.chunks.length,
+ serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ livePreviewContainedInSerialized:
+ livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
+ serializedPreview,
+ livePreview
+ }
+}
+
+export function saveStartupCaptures(
+ captures: StartupTerminalCapture[],
+ worktreeName: string
+): string {
+ const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
+ mkdirSync(dir, { recursive: true })
+ for (const capture of captures) {
+ const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
+ writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
+ writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts
index e4db6182f00..f3bbdc0d5d0 100644
--- a/mobile/scripts/repro-worktree-startup-stream.ts
+++ b/mobile/scripts/repro-worktree-startup-stream.ts
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveStartupCaptures,
+ summarizeStartupCapture,
+ type StartupTerminalCapture
+} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
-type Capture = {
- handle: string
- title: string | null
- scrollback: Record | null
- chunks: string[]
-}
-
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
-function stripAnsi(value: string): string {
- return (
- value
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
- .replace(/\r/g, '\n')
- )
-}
-
-function normalizePreview(value: string): string {
- return stripAnsi(value)
- .split('\n')
- .map((line) => line.trim())
- .filter(Boolean)
- .slice(0, 8)
- .join('\n')
-}
-
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
+async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise {
})
}
-function summarize(capture: Capture): Record {
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- const live = capture.chunks.join('')
- const serializedPreview = normalizePreview(serialized)
- const livePreview = normalizePreview(live)
- return {
- handle: capture.handle,
- title: capture.title,
- cols: capture.scrollback?.cols ?? null,
- rows: capture.scrollback?.rows ?? null,
- serializedBytes: Buffer.byteLength(serialized),
- liveBytes: Buffer.byteLength(live),
- liveChunks: capture.chunks.length,
- serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- livePreviewContainedInSerialized:
- livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
- serializedPreview,
- livePreview
- }
-}
-
-function save(captures: Capture[], worktreeName: string): string {
- const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
- mkdirSync(dir, { recursive: true })
- for (const capture of captures) {
- const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
- writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
- writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
- const dir = save(captures, worktreeName)
- console.table(captures.map(summarize))
+ const dir = saveStartupCaptures(captures, worktreeName)
+ console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts
index f938a98772c..0b25d44d3f0 100644
--- a/mobile/src/accounts/mobile-accounts-route.test.ts
+++ b/mobile/src/accounts/mobile-accounts-route.test.ts
@@ -7,7 +7,11 @@ import {
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
-const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
+const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
+const accountCardsSource = readFileSync(
+ new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url),
+ 'utf8'
+)
function navigationHarness(initialState: HostStackNavigationState) {
const stateListeners = new Set<() => void>()
@@ -83,14 +87,8 @@ describe('mobile accounts route', () => {
})
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
- const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
-
- // Assert the marker first: a renamed banner would otherwise slice garbage and report a
- // missing call instead of the real cause.
- expect(start).toBeGreaterThanOrEqual(0)
-
- const accountsSection = homeSource.slice(start)
- expect(accountsSection).toContain('openMobileAccounts(host.id)')
- expect(accountsSection).not.toContain('/accounts`')
+ expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}')
+ expect(accountCardsSource).toContain('props.onOpen(host.id)')
+ expect(accountCardsSource).not.toContain('/accounts`')
})
})
diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 4f66e0e0ac5..aef7bb1b8f4 100644
--- a/mobile/src/browser/MobileBrowserPane.tsx
+++ b/mobile/src/browser/MobileBrowserPane.tsx
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
-// 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 {
ActivityIndicator,
@@ -10,7 +7,6 @@ import {
PanResponder,
PixelRatio,
Pressable,
- StyleSheet,
Text,
TextInput,
View,
@@ -24,7 +20,7 @@ import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest,
@@ -52,6 +48,28 @@ import {
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
+import {
+ MAX_ZOOM,
+ MIN_ZOOM,
+ assertRpcOk,
+ browserErrorMessage,
+ browserFrameMetadataEqual,
+ buttonColor,
+ cacheBrowserFrame,
+ clearCachedBrowserFramesForWorktree,
+ createBrowserFrameDataUri,
+ createPinchGesture,
+ getCachedBrowserFrame,
+ makeBrowserFrameCacheKey,
+ peekCachedBrowserFrame,
+ shouldSurfaceBrowserError,
+ updateBrowserImageSource,
+ updateBrowserLayerVisibility,
+ updatePinchZoom,
+ type FrameLayer,
+ type PinchGesture
+} from './mobile-browser-frame-state'
+import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
@@ -79,7 +97,6 @@ type MobileBrowserPaneProps = {
onToast: (message: string, durationMs?: number) => void
}
-type FrameLayer = 0 | 1
type FrameLayerPair = [T, T]
type PendingFrameTransition = {
@@ -87,13 +104,6 @@ type PendingFrameTransition = {
decoderEpoch: number
}
-type PinchGesture = {
- distance: number
- scale: number
- anchorX: number
- anchorY: number
-}
-
type PanGesture = {
x: number
y: number
@@ -111,17 +121,7 @@ const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
const TOUCH_CLICK_RADIUS_DIP = 14
-const MIN_ZOOM = 1
-const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
-const BROWSER_FRAME_CACHE_LIMIT = 4
-
-type BrowserFrameCacheEntry = {
- uri: string
- metadata: BrowserScreencastFrameMetadata
-}
-
-const browserFrameCache = new Map()
type BrowserPageParams = {
worktree: string
@@ -1274,350 +1274,3 @@ export function MobileBrowserPane({
)
}
-
-function buttonColor(enabled: boolean): string {
- return enabled ? colors.textSecondary : colors.textMuted
-}
-
-function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
- return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
-}
-
-function makeBrowserFrameCacheKey(
- worktreeId: string,
- browserPageId: string | null,
- viewMode: MobileBrowserViewMode
-): string | null {
- return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
-}
-
-function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
- const prefix = `${worktreeId}:`
- for (const key of browserFrameCache.keys()) {
- if (key.startsWith(prefix)) {
- browserFrameCache.delete(key)
- }
- }
-}
-
-function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- if (!cacheKey) {
- return null
- }
- const cached = browserFrameCache.get(cacheKey)
- if (!cached) {
- return null
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, cached)
- return cached
-}
-
-function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
-}
-
-function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
- if (!cacheKey) {
- return
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, entry)
- while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
- const oldestKey = browserFrameCache.keys().next().value
- if (typeof oldestKey !== 'string') {
- break
- }
- browserFrameCache.delete(oldestKey)
- }
-}
-
-function updateBrowserLayerVisibility(
- layers: [View | null, View | null],
- visible: FrameLayer
-): void {
- for (const [index, layer] of layers.entries()) {
- layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
- }
-}
-
-function updateBrowserImageSource(image: Image | null, uri: string): void {
- // Why: browser frames are large strings; mutating only the native Image
- // source avoids re-rendering the whole tab view for every streamed frame.
- const source = [{ uri }]
- image?.setNativeProps({ source, src: source })
-}
-
-function assertRpcOk(
- response: RpcSuccess | RpcFailure,
- fallbackMessage: string
-): asserts response is RpcSuccess {
- if (!response.ok) {
- throw new Error(response.error.message || fallbackMessage)
- }
-}
-
-function browserFrameMetadataEqual(
- a: BrowserScreencastFrameMetadata | null,
- b: BrowserScreencastFrameMetadata
-): boolean {
- return (
- a?.deviceWidth === b.deviceWidth &&
- a?.deviceHeight === b.deviceHeight &&
- a?.pageScaleFactor === b.pageScaleFactor
- )
-}
-
-function browserErrorMessage(error: unknown, fallback: string): string {
- return error instanceof Error && error.message ? error.message : fallback
-}
-
-function shouldSurfaceBrowserError(message: string): boolean {
- const normalized = message.toLowerCase()
- // Why: selector_not_found can be emitted by in-flight page automation while
- // the browser is still usable; replacing the frame with it feels like a crash.
- return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
-}
-
-function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
- const touches = event.nativeEvent.touches
- if (!touches || touches.length < 2) {
- return null
- }
- const a = readLocalTouchPoint(touches[0])
- const b = readLocalTouchPoint(touches[1])
- return a && b ? { a, b } : null
-}
-
-function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
- return Math.hypot(a.x - b.x, a.y - b.y)
-}
-
-function createPinchGesture(
- event: GestureResponderEvent,
- geometry: BrowserFrameGeometry | null,
- zoom: BrowserZoomState
-): PinchGesture | null {
- if (!geometry) {
- return null
- }
- const pair = touchPair(event)
- if (!pair) {
- return null
- }
- const distance = pointDistance(pair.a, pair.b)
- if (distance < 8) {
- return null
- }
- const centerX = (pair.a.x + pair.b.x) / 2
- const centerY = (pair.a.y + pair.b.y) / 2
- const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
- const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
- return {
- distance,
- scale: zoom.scale,
- anchorX: (centerX - frameCenterX) / zoom.scale,
- anchorY: (centerY - frameCenterY) / zoom.scale
- }
-}
-
-function updatePinchZoom(
- event: GestureResponderEvent,
- geometry: BrowserFrameGeometry | null,
- pinch: PinchGesture
-): BrowserZoomState | null {
- if (!geometry) {
- return null
- }
- const pair = touchPair(event)
- if (!pair) {
- return null
- }
- const nextScale = Math.min(
- MAX_ZOOM,
- Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
- )
- const centerX = (pair.a.x + pair.b.x) / 2
- const centerY = (pair.a.y + pair.b.y) / 2
- const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
- const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
- return clampBrowserZoomState(
- {
- scale: nextScale,
- offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
- offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
- },
- geometry,
- MIN_ZOOM,
- MAX_ZOOM
- )
-}
-
-const styles = StyleSheet.create({
- root: {
- flex: 1,
- minHeight: 0,
- backgroundColor: colors.bgBase
- },
- toolbar: {
- minHeight: 32,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- paddingHorizontal: spacing.sm,
- paddingVertical: 2,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- viewport: {
- flex: 1,
- minHeight: 0,
- overflow: 'hidden',
- backgroundColor: colors.bgBase
- },
- browserImageHost: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden'
- },
- browserImageFill: {
- width: '100%',
- height: '100%'
- },
- browserImageLayer: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center'
- },
- browserImageLayerHidden: {
- opacity: 0
- },
- browserZoomOffset: {
- alignItems: 'center',
- justifyContent: 'center'
- },
- browserFrameBox: {
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden'
- },
- browserImage: {
- backgroundColor: colors.bgBase
- },
- overlay: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl,
- gap: spacing.sm,
- backgroundColor: 'rgba(13, 15, 24, 0.2)'
- },
- errorText: {
- color: colors.textPrimary,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: 13,
- textAlign: 'center',
- overflow: 'hidden'
- },
- dialogOverlay: {
- ...StyleSheet.absoluteFillObject,
- zIndex: 30,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl,
- backgroundColor: 'rgba(13, 15, 24, 0.5)'
- },
- dialogCard: {
- width: '100%',
- maxWidth: 360,
- borderRadius: radii.card,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel,
- padding: spacing.lg
- },
- dialogTitle: {
- color: colors.textPrimary,
- fontSize: 16,
- fontWeight: '600'
- },
- dialogMessage: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- lineHeight: 20,
- marginTop: spacing.sm
- },
- dialogActions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: spacing.sm,
- marginTop: spacing.lg
- },
- dialogButton: {
- minHeight: 34,
- borderRadius: radii.button,
- backgroundColor: colors.bgRaised,
- paddingHorizontal: spacing.md,
- alignItems: 'center',
- justifyContent: 'center'
- },
- dialogButtonPrimary: {
- backgroundColor: colors.textPrimary
- },
- dialogButtonPressed: {
- opacity: 0.75
- },
- dialogButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- dialogButtonPrimaryText: {
- color: colors.bgBase
- },
- keyboardDock: {
- zIndex: 20,
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- inputRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.md,
- paddingTop: spacing.xs,
- paddingBottom: spacing.xs + 2
- },
- keyboardInput: {
- flex: 1,
- height: 34,
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- marginRight: spacing.sm
- },
- sendButton: {
- width: 34,
- height: 34,
- borderRadius: 17,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgRaised
- },
- disabled: {
- opacity: 0.35
- },
- disabledText: {
- color: colors.textMuted
- }
-})
diff --git a/mobile/src/browser/mobile-browser-frame-state.ts b/mobile/src/browser/mobile-browser-frame-state.ts
new file mode 100644
index 00000000000..9609c2ffda4
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-frame-state.ts
@@ -0,0 +1,210 @@
+import { Buffer } from 'buffer'
+import type { GestureResponderEvent, Image, View } from 'react-native'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+import type {
+ BrowserScreencastFrame,
+ BrowserScreencastFrameMetadata
+} from '../transport/browser-screencast-protocol'
+import { colors } from '../theme/mobile-theme'
+import {
+ clampBrowserZoomState,
+ readLocalTouchPoint,
+ type BrowserFrameGeometry,
+ type BrowserPoint,
+ type BrowserZoomState
+} from './browser-touch-geometry'
+import type { MobileBrowserViewMode } from './browser-screencast-request'
+
+export type FrameLayer = 0 | 1
+export type PinchGesture = {
+ distance: number
+ scale: number
+ anchorX: number
+ anchorY: number
+}
+export type BrowserFrameCacheEntry = {
+ uri: string
+ metadata: BrowserScreencastFrameMetadata
+}
+export const MIN_ZOOM = 1
+export const MAX_ZOOM = 3.5
+const BROWSER_FRAME_CACHE_LIMIT = 4
+const browserFrameCache = new Map()
+
+export function buttonColor(enabled: boolean): string {
+ return enabled ? colors.textSecondary : colors.textMuted
+}
+
+export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
+ return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
+}
+
+export function makeBrowserFrameCacheKey(
+ worktreeId: string,
+ browserPageId: string | null,
+ viewMode: MobileBrowserViewMode
+): string | null {
+ return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
+}
+
+export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
+ const prefix = `${worktreeId}:`
+ for (const key of browserFrameCache.keys()) {
+ if (key.startsWith(prefix)) {
+ browserFrameCache.delete(key)
+ }
+ }
+}
+
+export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
+ if (!cacheKey) {
+ return null
+ }
+ const cached = browserFrameCache.get(cacheKey)
+ if (!cached) {
+ return null
+ }
+ browserFrameCache.delete(cacheKey)
+ browserFrameCache.set(cacheKey, cached)
+ return cached
+}
+
+export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
+ return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
+}
+
+export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
+ if (!cacheKey) {
+ return
+ }
+ browserFrameCache.delete(cacheKey)
+ browserFrameCache.set(cacheKey, entry)
+ while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
+ const oldestKey = browserFrameCache.keys().next().value
+ if (typeof oldestKey !== 'string') {
+ break
+ }
+ browserFrameCache.delete(oldestKey)
+ }
+}
+
+export function updateBrowserLayerVisibility(
+ layers: [View | null, View | null],
+ visible: FrameLayer
+): void {
+ for (const [index, layer] of layers.entries()) {
+ layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
+ }
+}
+
+export function updateBrowserImageSource(image: Image | null, uri: string): void {
+ // Why: browser frames are large strings; mutating only the native Image
+ // source avoids re-rendering the whole tab view for every streamed frame.
+ const source = [{ uri }]
+ image?.setNativeProps({ source, src: source })
+}
+
+export function assertRpcOk(
+ response: RpcSuccess | RpcFailure,
+ fallbackMessage: string
+): asserts response is RpcSuccess {
+ if (!response.ok) {
+ throw new Error(response.error.message || fallbackMessage)
+ }
+}
+
+export function browserFrameMetadataEqual(
+ a: BrowserScreencastFrameMetadata | null,
+ b: BrowserScreencastFrameMetadata
+): boolean {
+ return (
+ a?.deviceWidth === b.deviceWidth &&
+ a?.deviceHeight === b.deviceHeight &&
+ a?.pageScaleFactor === b.pageScaleFactor
+ )
+}
+
+export function browserErrorMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message ? error.message : fallback
+}
+
+export function shouldSurfaceBrowserError(message: string): boolean {
+ const normalized = message.toLowerCase()
+ // Why: selector_not_found can be emitted by in-flight page automation while
+ // the browser is still usable; replacing the frame with it feels like a crash.
+ return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
+}
+
+function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
+ const touches = event.nativeEvent.touches
+ if (!touches || touches.length < 2) {
+ return null
+ }
+ const a = readLocalTouchPoint(touches[0])
+ const b = readLocalTouchPoint(touches[1])
+ return a && b ? { a, b } : null
+}
+
+function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
+ return Math.hypot(a.x - b.x, a.y - b.y)
+}
+
+export function createPinchGesture(
+ event: GestureResponderEvent,
+ geometry: BrowserFrameGeometry | null,
+ zoom: BrowserZoomState
+): PinchGesture | null {
+ if (!geometry) {
+ return null
+ }
+ const pair = touchPair(event)
+ if (!pair) {
+ return null
+ }
+ const distance = pointDistance(pair.a, pair.b)
+ if (distance < 8) {
+ return null
+ }
+ const centerX = (pair.a.x + pair.b.x) / 2
+ const centerY = (pair.a.y + pair.b.y) / 2
+ const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
+ const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
+ return {
+ distance,
+ scale: zoom.scale,
+ anchorX: (centerX - frameCenterX) / zoom.scale,
+ anchorY: (centerY - frameCenterY) / zoom.scale
+ }
+}
+
+export function updatePinchZoom(
+ event: GestureResponderEvent,
+ geometry: BrowserFrameGeometry | null,
+ pinch: PinchGesture
+): BrowserZoomState | null {
+ if (!geometry) {
+ return null
+ }
+ const pair = touchPair(event)
+ if (!pair) {
+ return null
+ }
+ const nextScale = Math.min(
+ MAX_ZOOM,
+ Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
+ )
+ const centerX = (pair.a.x + pair.b.x) / 2
+ const centerY = (pair.a.y + pair.b.y) / 2
+ const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
+ const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
+ return clampBrowserZoomState(
+ {
+ scale: nextScale,
+ offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
+ offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
+ },
+ geometry,
+ MIN_ZOOM,
+ MAX_ZOOM
+ )
+}
diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts
new file mode 100644
index 00000000000..b41428516ba
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-pane-styles.ts
@@ -0,0 +1,171 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileBrowserPaneStyles = StyleSheet.create({
+ root: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.bgBase
+ },
+ toolbar: {
+ minHeight: 32,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ viewport: {
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+ backgroundColor: colors.bgBase
+ },
+ browserImageHost: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden'
+ },
+ browserImageFill: {
+ width: '100%',
+ height: '100%'
+ },
+ browserImageLayer: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ browserImageLayerHidden: {
+ opacity: 0
+ },
+ browserZoomOffset: {
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ browserFrameBox: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden'
+ },
+ browserImage: {
+ backgroundColor: colors.bgBase
+ },
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ gap: spacing.sm,
+ backgroundColor: 'rgba(13, 15, 24, 0.2)'
+ },
+ errorText: {
+ color: colors.textPrimary,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ fontSize: 13,
+ textAlign: 'center',
+ overflow: 'hidden'
+ },
+ dialogOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 30,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ backgroundColor: 'rgba(13, 15, 24, 0.5)'
+ },
+ dialogCard: {
+ width: '100%',
+ maxWidth: 360,
+ borderRadius: radii.card,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel,
+ padding: spacing.lg
+ },
+ dialogTitle: {
+ color: colors.textPrimary,
+ fontSize: 16,
+ fontWeight: '600'
+ },
+ dialogMessage: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ lineHeight: 20,
+ marginTop: spacing.sm
+ },
+ dialogActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: spacing.sm,
+ marginTop: spacing.lg
+ },
+ dialogButton: {
+ minHeight: 34,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ dialogButtonPrimary: {
+ backgroundColor: colors.textPrimary
+ },
+ dialogButtonPressed: {
+ opacity: 0.75
+ },
+ dialogButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ dialogButtonPrimaryText: {
+ color: colors.bgBase
+ },
+ keyboardDock: {
+ zIndex: 20,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.xs + 2
+ },
+ keyboardInput: {
+ flex: 1,
+ height: 34,
+ backgroundColor: colors.bgRaised,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ marginRight: spacing.sm
+ },
+ sendButton: {
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised
+ },
+ disabled: {
+ opacity: 0.35
+ },
+ disabledText: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx
index 673bbff049e..a5e81ec07a9 100644
--- a/mobile/src/components/AgentSpinner.tsx
+++ b/mobile/src/components/AgentSpinner.tsx
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react'
+import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
+import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -17,11 +19,18 @@ const STATUS_COLORS: Record = {
inactive: 'rgba(115,115,115,0.4)'
}
-export function AgentSpinner({ status }: { status: WorktreeStatus }) {
+export function AgentSpinner({
+ status,
+ workingMode
+}: {
+ status: WorktreeStatus
+ workingMode?: AgentWorkingMode
+}) {
const spinValue = useRef(new Animated.Value(0)).current
+ const monitoring = status === 'working' && workingMode === 'monitoring'
useEffect(() => {
- if (status === 'working') {
+ if (status === 'working' && !monitoring) {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
@@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
return () => animation.stop()
}
spinValue.setValue(0)
- }, [status, spinValue])
+ }, [monitoring, status, spinValue])
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
+ if (monitoring) {
+ return (
+
+
+
+ )
+ }
+
if (status === 'working') {
const rotate = spinValue.interpolate({
inputRange: [0, 1],
diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx
index 916525ef366..4d596b7e094 100644
--- a/mobile/src/components/AgentStateDot.tsx
+++ b/mobile/src/components/AgentStateDot.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
+import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display'
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
// agent vocabulary into the 5-state rollup the sidebar dot uses.
-const DOT_COLORS: Record, string> = {
+const DOT_COLORS: Record, string> = {
done: '#10b981',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
}
+const WORKING_COLOR = '#eab308'
export function AgentStateDot({ state }: { state: AgentDotState }) {
const spinValue = useRef(new Animated.Value(0)).current
@@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
)
}
+ if (state === 'monitoring') {
+ return (
+
+
+
+ )
+ }
+
return (
@@ -59,7 +69,7 @@ const styles = StyleSheet.create({
height: 6,
borderRadius: 3,
borderWidth: 1.5,
- borderColor: '#eab308',
+ borderColor: WORKING_COLOR,
borderTopColor: 'transparent'
}
})
diff --git a/mobile/src/components/ConnectionLog.test.tsx b/mobile/src/components/ConnectionLog.test.tsx
new file mode 100644
index 00000000000..c310833f157
--- /dev/null
+++ b/mobile/src/components/ConnectionLog.test.tsx
@@ -0,0 +1,81 @@
+import { createElement } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectionLogEntry } from '../transport/types'
+import { ConnectionLog } from './ConnectionLog'
+
+vi.mock('react-native', () => ({
+ ScrollView: 'ScrollView',
+ StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 },
+ Text: 'Text',
+ View: 'View'
+}))
+
+const entries: ConnectionLogEntry[] = [
+ { id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
+]
+
+const duplicateEntries: ConnectionLogEntry[] = [
+ { id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
+ { id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
+]
+
+describe('ConnectionLog', () => {
+ type RenderedNode = { props: { style?: unknown } }
+ type Renderer = {
+ root: {
+ findAllByType: (type: unknown) => RenderedNode[]
+ findByType: (type: unknown) => RenderedNode
+ }
+ unmount: () => void
+ }
+
+ let renderer: Renderer | null = null
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function renderLog(fillAvailableHeight = false): Renderer {
+ act(() => {
+ renderer = create(
+ createElement(ConnectionLog, { entries, fillAvailableHeight })
+ ) as unknown as Renderer
+ })
+ return renderer as Renderer
+ }
+
+ it('keeps the compact height in pairing flows', () => {
+ const instance = renderLog()
+ const containerStyles = instance.root.findAllByType('View')[0]!.props.style
+
+ expect(containerStyles).toContainEqual({ maxHeight: 240 })
+ })
+
+ it('fills the available diagnostics viewport', () => {
+ const instance = renderLog(true)
+ const containerStyles = instance.root.findAllByType('View')[0]!.props.style
+ const scroll = instance.root.findByType('ScrollView')
+
+ expect(containerStyles).toContainEqual({ flex: 1 })
+ expect(scroll.props.style).toEqual({ flex: 1 })
+ })
+
+ it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ act(() => {
+ renderer = create(
+ createElement(ConnectionLog, { entries: duplicateEntries })
+ ) as unknown as Renderer
+ })
+
+ expect(
+ consoleError.mock.calls.some(([message]) =>
+ String(message).includes('Encountered two children with the same key')
+ )
+ ).toBe(false)
+
+ consoleError.mockRestore()
+ })
+})
diff --git a/mobile/src/components/ConnectionLog.tsx b/mobile/src/components/ConnectionLog.tsx
index ad49e46d870..08f467d943f 100644
--- a/mobile/src/components/ConnectionLog.tsx
+++ b/mobile/src/components/ConnectionLog.tsx
@@ -8,6 +8,7 @@ type Props = {
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
+ fillAvailableHeight?: boolean
}
const LEVEL_COLOR: Record = {
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
return `+${Math.round(elapsed)}s`
}
-export function ConnectionLog({ entries, title }: Props) {
+export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
const scrollRef = useRef(null)
if (entries.length === 0) {
return null
}
const baseTs = entries[0]!.ts
+ const keyOccurrences = new Map()
return (
-
+
{title && {title}}
scrollRef.current?.scrollToEnd({ animated: true })}
>
- {entries.map((entry) => (
-
- {formatTime(entry.ts, baseTs)}
-
- {LEVEL_GLYPH[entry.level]}
-
-
-
- {entry.message}
+ {entries.map((entry) => {
+ const occurrence = keyOccurrences.get(entry.id) ?? 0
+ keyOccurrences.set(entry.id, occurrence + 1)
+ const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
+ return (
+
+ {formatTime(entry.ts, baseTs)}
+
+ {LEVEL_GLYPH[entry.level]}
- {entry.detail && (
-
- {entry.detail}
+
+
+ {entry.message}
- )}
+ {entry.detail && (
+
+ {entry.detail}
+
+ )}
+
-
- ))}
+ )
+ })}
)
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
const styles = StyleSheet.create({
container: {
width: '100%',
- maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
+ boundedContainer: {
+ maxHeight: 240
+ },
+ fillContainer: {
+ flex: 1
+ },
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.xs
},
- scroll: {
+ boundedScroll: {
maxHeight: 200
},
+ fillScroll: {
+ flex: 1
+ },
scrollContent: {
gap: 6
},
diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts
new file mode 100644
index 00000000000..991e557441f
--- /dev/null
+++ b/mobile/src/components/CustomKeyModal.styles.ts
@@ -0,0 +1,276 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+
+export const customKeyModalStyles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingBottom: spacing.sm
+ },
+ backButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ backButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ backSpacer: {
+ width: 30
+ },
+ title: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ textAlign: 'center'
+ },
+ group: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden'
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ row: {
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary,
+ marginBottom: 1
+ },
+ rowHint: {
+ fontSize: 12,
+ color: colors.textMuted
+ },
+ shortcutForm: {
+ paddingTop: spacing.sm
+ },
+ preview: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.lg + spacing.xs,
+ flexWrap: 'wrap'
+ },
+ previewKeycapRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ previewPlus: {
+ color: colors.textMuted,
+ fontSize: 16
+ },
+ keycap: {
+ minWidth: 48,
+ height: 48,
+ paddingHorizontal: spacing.md,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keycapModifier: {
+ minWidth: 0
+ },
+ keycapWarn: {
+ borderColor: colors.statusAmber
+ },
+ keycapText: {
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 17,
+ fontWeight: '600'
+ },
+ keycapTextWarn: {
+ color: colors.statusAmber
+ },
+ keycapModifierText: {
+ color: colors.textSecondary,
+ fontFamily: typography.monoFamily,
+ fontSize: 14,
+ fontWeight: '600'
+ },
+ section: {
+ marginTop: spacing.md
+ },
+ sectionLabel: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ marginBottom: spacing.sm,
+ paddingLeft: 2
+ },
+ mods: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ chip: {
+ flex: 1,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4
+ },
+ chipSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ chipPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ chipText: {
+ color: colors.textSecondary,
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ chipTextSelected: {
+ color: colors.bgBase
+ },
+ chipGlyph: {
+ color: colors.textMuted,
+ fontSize: 13,
+ fontFamily: typography.monoFamily
+ },
+ chipGlyphSelected: {
+ color: 'rgba(10,10,10,0.5)'
+ },
+ keyInput: {
+ width: '100%',
+ height: 56,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 22,
+ fontWeight: '600',
+ textAlign: 'center'
+ },
+ moreLink: {
+ paddingVertical: spacing.sm,
+ alignItems: 'center'
+ },
+ moreLinkPressed: {
+ opacity: 0.6
+ },
+ moreLinkText: {
+ color: colors.textSecondary,
+ fontSize: 13,
+ textDecorationLine: 'underline'
+ },
+ specialKeysForm: {
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.md,
+ gap: spacing.md
+ },
+ specialGroup: {
+ gap: spacing.xs
+ },
+ specialGroupTitle: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ paddingLeft: 2,
+ marginBottom: spacing.xs
+ },
+ keyGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginHorizontal: -spacing.xs / 2
+ },
+ keyCellWrap: {
+ paddingHorizontal: spacing.xs / 2,
+ paddingVertical: spacing.xs / 2
+ },
+ keyCell: {
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keyCellPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ keyCellSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ keyCellText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily
+ },
+ keyCellTextSelected: {
+ color: colors.bgBase
+ },
+ macroForm: {
+ padding: spacing.md,
+ gap: spacing.sm
+ },
+ fieldLabel: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: colors.textSecondary
+ },
+ fieldInput: {
+ backgroundColor: colors.bgBase,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ switchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.xs
+ },
+ switchLabel: {
+ fontSize: typography.bodySize,
+ color: colors.textPrimary
+ },
+ saveButton: {
+ marginTop: spacing.md,
+ backgroundColor: colors.textPrimary,
+ paddingVertical: spacing.md,
+ borderRadius: 10,
+ alignItems: 'center'
+ },
+ saveButtonDisabled: {
+ backgroundColor: colors.bgRaised
+ },
+ saveButtonText: {
+ color: colors.bgBase,
+ fontSize: 15,
+ fontWeight: '600'
+ },
+ saveButtonTextDisabled: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx
index 2777e310dc8..0cc24f4c030 100644
--- a/mobile/src/components/CustomKeyModal.tsx
+++ b/mobile/src/components/CustomKeyModal.tsx
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
-import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
+import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
+import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
)
}
-
-const styles = StyleSheet.create({
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingBottom: spacing.sm
- },
- backButton: {
- width: 30,
- height: 30,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center'
- },
- backButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- backSpacer: {
- width: 30
- },
- title: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary,
- textAlign: 'center'
- },
- group: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden'
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- row: {
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- rowLabel: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary,
- marginBottom: 1
- },
- rowHint: {
- fontSize: 12,
- color: colors.textMuted
- },
- shortcutForm: {
- paddingTop: spacing.sm
- },
- preview: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.sm,
- paddingVertical: spacing.lg + spacing.xs,
- flexWrap: 'wrap'
- },
- previewKeycapRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- previewPlus: {
- color: colors.textMuted,
- fontSize: 16
- },
- keycap: {
- minWidth: 48,
- height: 48,
- paddingHorizontal: spacing.md,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keycapModifier: {
- minWidth: 0
- },
- keycapWarn: {
- borderColor: colors.statusAmber
- },
- keycapText: {
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 17,
- fontWeight: '600'
- },
- keycapTextWarn: {
- color: colors.statusAmber
- },
- keycapModifierText: {
- color: colors.textSecondary,
- fontFamily: typography.monoFamily,
- fontSize: 14,
- fontWeight: '600'
- },
- section: {
- marginTop: spacing.md
- },
- sectionLabel: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- marginBottom: spacing.sm,
- paddingLeft: 2
- },
- mods: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- chip: {
- flex: 1,
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 4
- },
- chipSelected: {
- backgroundColor: colors.textPrimary
- },
- chipPressed: {
- backgroundColor: colors.bgRaised
- },
- chipText: {
- color: colors.textSecondary,
- fontSize: 14,
- fontWeight: '500'
- },
- chipTextSelected: {
- color: colors.bgBase
- },
- chipGlyph: {
- color: colors.textMuted,
- fontSize: 13,
- fontFamily: typography.monoFamily
- },
- chipGlyphSelected: {
- color: 'rgba(10,10,10,0.5)'
- },
- keyInput: {
- width: '100%',
- height: 56,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 22,
- fontWeight: '600',
- textAlign: 'center'
- },
- moreLink: {
- paddingVertical: spacing.sm,
- alignItems: 'center'
- },
- moreLinkPressed: {
- opacity: 0.6
- },
- moreLinkText: {
- color: colors.textSecondary,
- fontSize: 13,
- textDecorationLine: 'underline'
- },
- specialKeysForm: {
- paddingTop: spacing.xs,
- paddingBottom: spacing.md,
- gap: spacing.md
- },
- specialGroup: {
- gap: spacing.xs
- },
- specialGroupTitle: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- paddingLeft: 2,
- marginBottom: spacing.xs
- },
- keyGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- marginHorizontal: -spacing.xs / 2
- },
- keyCellWrap: {
- paddingHorizontal: spacing.xs / 2,
- paddingVertical: spacing.xs / 2
- },
- keyCell: {
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keyCellPressed: {
- backgroundColor: colors.bgRaised
- },
- keyCellSelected: {
- backgroundColor: colors.textPrimary
- },
- keyCellText: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary,
- fontFamily: typography.monoFamily
- },
- keyCellTextSelected: {
- color: colors.bgBase
- },
- macroForm: {
- padding: spacing.md,
- gap: spacing.sm
- },
- fieldLabel: {
- fontSize: 13,
- fontWeight: '500',
- color: colors.textSecondary
- },
- fieldInput: {
- backgroundColor: colors.bgBase,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- switchRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingVertical: spacing.xs
- },
- switchLabel: {
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- saveButton: {
- marginTop: spacing.md,
- backgroundColor: colors.textPrimary,
- paddingVertical: spacing.md,
- borderRadius: 10,
- alignItems: 'center'
- },
- saveButtonDisabled: {
- backgroundColor: colors.bgRaised
- },
- saveButtonText: {
- color: colors.bgBase,
- fontSize: 15,
- fontWeight: '600'
- },
- saveButtonTextDisabled: {
- color: colors.textMuted
- }
-})
diff --git a/mobile/src/components/HostDiagnosticsLink.tsx b/mobile/src/components/HostDiagnosticsLink.tsx
new file mode 100644
index 00000000000..eaab1f96ca4
--- /dev/null
+++ b/mobile/src/components/HostDiagnosticsLink.tsx
@@ -0,0 +1,38 @@
+import { ChevronRight } from 'lucide-react-native'
+import { Pressable, StyleSheet, Text } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
+ return (
+
+ View network diagnostics
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ link: {
+ marginHorizontal: spacing.md,
+ marginBottom: spacing.sm,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ },
+ text: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/src/components/NewWorkspaceSetupScriptField.tsx b/mobile/src/components/NewWorkspaceSetupScriptField.tsx
new file mode 100644
index 00000000000..84e28c5de33
--- /dev/null
+++ b/mobile/src/components/NewWorkspaceSetupScriptField.tsx
@@ -0,0 +1,76 @@
+import { Pressable, Switch, Text, View } from 'react-native'
+import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
+import { colors } from '../theme/mobile-theme'
+import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
+import type { SetupRunPolicy } from './new-worktree-modal-types'
+
+export function NewWorkspaceSetupScriptField({
+ command,
+ source,
+ runPolicy,
+ decision,
+ runSetup,
+ onDecisionChange,
+ onRunSetupChange
+}: {
+ command: string
+ source: string | null
+ runPolicy: SetupRunPolicy
+ decision: Exclude | null
+ runSetup: boolean
+ onDecisionChange: (decision: Exclude) => void
+ onRunSetupChange: (run: boolean) => void
+}) {
+ return (
+
+
+ Setup script
+ {source ? (
+
+
+ {source === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
+
+
+ ) : null}
+
+
+ {runPolicy === 'ask' ? (
+
+ onDecisionChange('run')}
+ >
+ Run
+
+ onDecisionChange('skip')}
+ >
+ Skip
+
+
+ ) : (
+
+ Run setup command
+
+
+ )}
+
+ {command}
+
+
+
+ )
+}
diff --git a/mobile/src/components/NewWorkspaceSshConnectionField.tsx b/mobile/src/components/NewWorkspaceSshConnectionField.tsx
new file mode 100644
index 00000000000..38e3eeaea9b
--- /dev/null
+++ b/mobile/src/components/NewWorkspaceSshConnectionField.tsx
@@ -0,0 +1,52 @@
+import { Pressable, Text, View } from 'react-native'
+import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
+import { workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
+import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
+
+export function NewWorkspaceSshConnectionField({
+ repoName,
+ sshGate,
+ onConnect
+}: {
+ repoName: string
+ sshGate: WorkspaceSshGate
+ onConnect: () => void
+}) {
+ return (
+
+ SSH Connection
+
+
+
+
+
+ {repoName}
+
+ {workspaceSshStatusLabel(sshGate.status)}
+
+ {sshGate.status === 'connected' ? null : (
+
+
+ {sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
+
+
+ )}
+
+ {sshGate.error ? {sshGate.error} : null}
+
+
+ )
+}
diff --git a/mobile/src/components/NewWorktreeFormSheet.tsx b/mobile/src/components/NewWorktreeFormSheet.tsx
new file mode 100644
index 00000000000..2a6587ffa87
--- /dev/null
+++ b/mobile/src/components/NewWorktreeFormSheet.tsx
@@ -0,0 +1,184 @@
+import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
+import { ChevronDown, ChevronUp } from 'lucide-react-native'
+import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
+import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
+import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import { colors } from '../theme/mobile-theme'
+import { BottomDrawer } from './BottomDrawer'
+import { MobileAgentIcon } from './MobileAgentIcon'
+import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
+import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
+import type { SetupRunPolicy } from './new-worktree-modal-types'
+import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
+import { NewWorkspaceSetupScriptField } from './NewWorkspaceSetupScriptField'
+import { NewWorkspaceSshConnectionField } from './NewWorkspaceSshConnectionField'
+import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
+import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
+
+type Composer = ReturnType
+type Selection = { label: string; detail?: string }
+
+export function NewWorktreeFormSheet(props: {
+ visible: boolean
+ interactive: boolean
+ loading: boolean
+ hasRepos: boolean
+ project: Selection | null
+ runTarget: Selection | null
+ projectBadgeColor: string | null
+ selectedRepoIsGit: boolean
+ selectedRepoConnectionId: string | null
+ selectedRepoName: string
+ sshGate: WorkspaceSshGate
+ composer: Composer
+ selectedAgent: NewWorktreeAgentOption
+ showAdvanced: boolean
+ note: string
+ setupCommand: string | null
+ setupSource: string | null
+ setupRunPolicy: SetupRunPolicy
+ setupDecisionChoice: Exclude | null
+ runSetup: boolean
+ error: string
+ creating: boolean
+ canCreate: boolean
+ onClose: () => void
+ onOpenProject: () => void
+ onOpenRunTarget: () => void
+ onOpenSource: () => void
+ onClearError: () => void
+ onConnect: () => void
+ onOpenAgent: () => void
+ onShowAdvancedChange: (show: boolean) => void
+ onNoteChange: (note: string) => void
+ onSetupDecisionChange: (decision: Exclude) => void
+ onRunSetupChange: (run: boolean) => void
+ onCreate: () => void
+}) {
+ return (
+
+
+ Create worktree
+
+
+ {props.loading ? (
+
+
+
+ ) : !props.hasRepos ? (
+
+ No projects found
+
+ ) : (
+ <>
+
+
+
+
+ {props.composer.forkPushWarning ? (
+ {props.composer.forkPushWarning}
+ ) : null}
+
+ {props.selectedRepoConnectionId ? (
+
+ ) : null}
+
+
+ Agent
+
+
+
+ {props.sshGate.requiresConnection
+ ? 'Connect target first'
+ : props.selectedAgent.label}
+
+
+
+
+
+ props.onShowAdvancedChange(!props.showAdvanced)}
+ >
+ Advanced
+ {props.showAdvanced ? (
+
+ ) : (
+
+ )}
+
+
+ {props.showAdvanced ? (
+ <>
+
+
+ Note
+
+
+ {props.setupCommand ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ {props.error ? {props.error} : null}
+
+
+ {props.creating ? (
+
+ ) : (
+
+ {props.sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
+
+ )}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx
index 4d954a2464f..0bbdb462d4c 100644
--- a/mobile/src/components/NewWorktreeModal.test.tsx
+++ b/mobile/src/components/NewWorktreeModal.test.tsx
@@ -68,6 +68,21 @@ function pickerItems(
return picker?.props.items ?? []
}
+function sourceInputs(renderer: ReactTestRenderer) {
+ return renderer.root.findAll(
+ (node) =>
+ node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
+ )
+}
+
+async function flushUpdates(): Promise {
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
@@ -164,4 +179,224 @@ describe('NewWorktreeModal project targets', () => {
})
])
})
+
+ it('loads SSH state before detecting agents on the selected remote target', async () => {
+ const remoteRepo = {
+ id: 'repo-remote',
+ displayName: 'orca',
+ path: '/home/dev/orca',
+ connectionId: 'build-server',
+ kind: 'git',
+ upstream: { owner: 'stablyai', repo: 'orca' }
+ }
+ setCachedRepos('host-ssh', [remoteRepo])
+ const sendRequest = vi.fn().mockImplementation((method: string) => {
+ if (method === 'repo.list') {
+ return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
+ }
+ if (method === 'ssh.getState') {
+ return Promise.resolve({
+ ok: true,
+ result: {
+ state: {
+ targetId: 'build-server',
+ status: 'connected',
+ error: null,
+ reconnectAttempt: 0
+ }
+ }
+ })
+ }
+ if (method === 'preflight.detectRemoteAgents') {
+ return Promise.resolve({ ok: true, result: ['codex'] })
+ }
+ if (method === 'settings.get') {
+ return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
+ }
+ if (method === 'ui.get') {
+ return Promise.resolve({ ok: true, result: { ui: {} } })
+ }
+ if (method === 'preflight.check') {
+ return Promise.resolve({ ok: true, result: {} })
+ }
+ if (method === 'linear.status') {
+ return Promise.resolve({ ok: true, result: {} })
+ }
+ if (method === 'status.get') {
+ return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
+ }
+ if (method === 'repo.hooks') {
+ return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
+ }
+ return Promise.resolve({ ok: true, result: {} })
+ })
+ const client = { sendRequest } as unknown as RpcClient
+
+ await act(async () => {
+ renderer = create(
+ createElement(NewWorktreeModal, {
+ visible: true,
+ client,
+ hostId: 'host-ssh',
+ onCreated: () => {},
+ onClose: () => {}
+ })
+ )
+ })
+ await flushUpdates()
+
+ expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
+ expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
+ connectionId: 'build-server'
+ })
+ })
+
+ it('keeps folder workspaces on the local execution and text-only source path', async () => {
+ const folderRepo = {
+ id: 'folder-1',
+ displayName: 'notes',
+ path: '/src/notes',
+ kind: 'folder'
+ }
+ setCachedRepos('host-folder', [folderRepo])
+ const sendRequest = vi.fn().mockImplementation((method: string) => {
+ if (method === 'repo.list') {
+ return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
+ }
+ if (method === 'preflight.detectAgents') {
+ return Promise.resolve({ ok: true, result: ['codex'] })
+ }
+ if (method === 'settings.get') {
+ return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
+ }
+ if (method === 'ui.get') {
+ return Promise.resolve({ ok: true, result: { ui: {} } })
+ }
+ if (method === 'preflight.check') {
+ return Promise.resolve({ ok: true, result: {} })
+ }
+ if (method === 'linear.status') {
+ return Promise.resolve({ ok: true, result: {} })
+ }
+ if (method === 'status.get') {
+ return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
+ }
+ if (method === 'repo.hooks') {
+ return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
+ }
+ return Promise.resolve({ ok: true, result: {} })
+ })
+ const client = { sendRequest } as unknown as RpcClient
+
+ await act(async () => {
+ renderer = create(
+ createElement(NewWorktreeModal, {
+ visible: true,
+ client,
+ hostId: 'host-folder',
+ onCreated: () => {},
+ onClose: () => {}
+ })
+ )
+ })
+ await flushUpdates()
+
+ expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
+ expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
+ expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
+ })
+
+ it('ignores a stale repo list after the client changes', async () => {
+ let resolveOldList: ((value: unknown) => void) | undefined
+ const oldList = new Promise((resolve) => {
+ resolveOldList = resolve
+ })
+ const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
+ const oldClient = {
+ sendRequest: vi.fn().mockImplementation((method: string) => {
+ if (method === 'repo.list') {
+ return oldList
+ }
+ return new Promise(() => {})
+ })
+ } as unknown as RpcClient
+ const freshClient = {
+ sendRequest: vi.fn().mockImplementation((method: string) => {
+ if (method === 'repo.list') {
+ return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
+ }
+ if (method === 'status.get') {
+ return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
+ }
+ return new Promise(() => {})
+ })
+ } as unknown as RpcClient
+ const modalProps = {
+ visible: true,
+ hostId: 'host-1',
+ onCreated: () => {},
+ onClose: () => {}
+ }
+
+ await act(async () => {
+ renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
+ })
+ const sourceInput = sourceInputs(renderer)[0]!
+ act(() => sourceInput.props.onChangeText('stale-client-name'))
+ expect(sourceInput.props.value).toBe('stale-client-name')
+ await act(async () => {
+ renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
+ await Promise.resolve()
+ })
+ expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
+ resolveOldList?.({ ok: true, result: { repos } })
+ await flushUpdates()
+
+ expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
+ })
+
+ it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
+ const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
+ const client = { sendRequest } as unknown as RpcClient
+ const modalProps = {
+ client,
+ hostId: 'host-1',
+ onCreated: () => {},
+ onClose: () => {}
+ }
+
+ await act(async () => {
+ renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
+ })
+ act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
+ act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
+
+ // One repo.list per opening. A third means the previous session's instance
+ // re-ran its visible-gated effects before the remount key caught up.
+ const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
+ expect(repoListCalls).toHaveLength(2)
+ })
+
+ it('starts with fresh form state after closing and reopening', async () => {
+ const client = {
+ sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
+ } as unknown as RpcClient
+ const modalProps = {
+ client,
+ hostId: 'host-1',
+ onCreated: () => {},
+ onClose: () => {}
+ }
+
+ await act(async () => {
+ renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
+ })
+ const sourceInput = sourceInputs(renderer)[0]!
+ act(() => sourceInput.props.onChangeText('previous-workspace'))
+
+ act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
+ act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
+
+ expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
+ })
})
diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx
index b05127389f2..92bac7d59bb 100644
--- a/mobile/src/components/NewWorktreeModal.tsx
+++ b/mobile/src/components/NewWorktreeModal.tsx
@@ -1,209 +1,98 @@
-import { useState, useEffect, useMemo, useRef } from 'react'
-import {
- View,
- Text,
- TextInput,
- Pressable,
- Switch,
- StyleSheet,
- Platform,
- ActivityIndicator,
- Keyboard
-} from 'react-native'
-import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
-import type { RpcClient } from '../transport/rpc-client'
-import type { RpcResponse, RpcSuccess } from '../transport/types'
-import { colors, spacing, radii, typography } from '../theme/mobile-theme'
-import { BottomDrawer } from './BottomDrawer'
-import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
-import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
-import { PickerListDrawer } from './PickerListDrawer'
-import { MobileAgentIcon } from './MobileAgentIcon'
-import { getSuggestedCreatureName } from './worktree-name-suggestion'
-import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
-import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
-import {
- isSetupHookTrusted,
- normalizeSetupHookTrust,
- persistSetupHookTrustApproval,
- wasSetupHookPreviouslyApproved,
- type SetupHookTrust
-} from '../tasks/setup-hook-trust'
-import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
-import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
-import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
-import type { TuiAgent } from '../../../src/shared/tui-agent'
-import type { SshConnectionState } from '../../../src/shared/ssh-types'
+import { useMemo, useRef, useState } from 'react'
+import { Keyboard } from 'react-native'
+import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
-import {
- NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
- NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
- pickPreferredNewWorktreeAgent,
- resolveNewWorktreeAgentSelection,
- type NewWorktreeAgentOption as AgentOption
-} from './new-worktree-agent-selection'
-import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
-import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo'
-import {
- getMobileNewWorkspaceDialogEligibleRepos,
- refreshMobileNewWorkspaceDialogSelectedRepo,
- resolveMobileNewWorkspaceDialogRepoId
-} from '../worktree/new-workspace-dialog-repo-selection'
-import { createBlankWorkspace } from '../tasks/blank-workspace-create'
-import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
-import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
-import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
-import {
- filterAvailableTaskProviders,
- normalizeVisibleTaskProviders,
- type TaskProvider
-} from '../tasks/mobile-task-providers'
-import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
-import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
-import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
-import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
-import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
-import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
-import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
-import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
+import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
+import {
+ buildRetiredWorktreeNamesRefreshKey,
+ useRetiredWorktreeNames
+} from '../worktree/use-retired-worktree-names'
+import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
+import {
+ getMobileWorkspaceRepoBadgeColor,
+ type MobileWorkspaceRepo,
+ type NewWorktreeModalProps
+} from './new-worktree-modal-types'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
+import { NewWorktreeFormSheet } from './NewWorktreeFormSheet'
+import { NewWorktreeModalDrawers } from './NewWorktreeModalDrawers'
+import { useNewWorkspaceAgentSelection } from './use-new-workspace-agent-selection'
+import { useNewWorkspaceCreateSubmit } from './use-new-workspace-create-submit'
+import { useNewWorkspaceExecutionTarget } from './use-new-workspace-execution-target'
+import { useNewWorkspaceRepositories } from './use-new-workspace-repositories'
+import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-context'
+import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
+import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
-type Repo = Pick &
- Partial<
- Pick<
- SharedRepo,
- | 'badgeColor'
- | 'connectionId'
- | 'executionHostId'
- | 'kind'
- | 'upstream'
- | 'repoIcon'
- | 'gitRemoteIdentity'
- >
- >
-
-type SetupDecision = 'inherit' | 'run' | 'skip'
-type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
-type RuntimeSettings = {
- defaultTuiAgent?: TuiAgent | 'blank' | null
- disabledTuiAgents?: TuiAgent[]
-}
-
-type RepoHooksResponse = {
- hooks: { scripts?: { setup?: string } } | null
- source: string | null
- setupRunPolicy?: SetupRunPolicy
- setupTrust?: SetupHookTrust
-}
-
-type SetupHookDetails = {
- repoId: string
- command: string | null
- source: string | null
- trust: SetupHookTrust | null
- runPolicy: SetupRunPolicy
-}
-
-type DetectedAgentIdsState = {
- connectionId: string | null
- ids: Set
-}
-
-type CreateOptions = {
- setupOverride?: Exclude
- approvedSetupContentHash?: string
-}
-
-function repoColor(name: string): string {
- const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
- let hash = 0
- for (let i = 0; i < name.length; i += 1) {
- hash = (hash * 31 + name.charCodeAt(i)) | 0
- }
- return palette[Math.abs(hash) % palette.length]!
-}
-
-function repoBadgeColor(repo: Repo | null): string {
- return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
-}
-
-// ── Main modal ──────────────────────────────────────────────────────
-
-type Props = {
- visible: boolean
- client: RpcClient | null
- hostId?: string
- // Why: existing worktree paths from the host so we can pick a unique
- // marine-creature default when the user leaves the name blank, matching
- // the desktop UI's behavior. The "already exists locally" collision is
- // on the on-disk directory basename, so paths (not displayNames) are
- // what the suggestion logic must dedupe against.
- existingWorktreePaths?: readonly string[]
- existingWorktrees?: readonly { repoId: string; branch: string }[]
- onCreated: (worktreeId: string, name: string) => void
- onClose: () => void
-}
-
-export function NewWorktreeModal({
- visible,
- client,
- hostId,
- existingWorktreePaths,
- existingWorktrees,
- onCreated,
- onClose
-}: Props) {
+export function NewWorktreeModal(props: NewWorktreeModalProps) {
const openEpochRef = useRef(0)
const wasVisibleRef = useRef(false)
- const clientEpochRef = useRef({ client, epoch: 0 })
+ const clientEpochRef = useRef({ client: props.client, epoch: 0 })
// Why: each drawer opening is a fresh form session; remounting resets local
// form state before paint instead of clearing it in a visible-prop Effect.
- if (visible && !wasVisibleRef.current) {
+ if (props.visible && !wasVisibleRef.current) {
openEpochRef.current += 1
}
- wasVisibleRef.current = visible
- if (clientEpochRef.current.client !== client) {
- clientEpochRef.current = { client, epoch: clientEpochRef.current.epoch + 1 }
+ wasVisibleRef.current = props.visible
+ if (clientEpochRef.current.client !== props.client) {
+ clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 }
}
return (
)
}
-function NewWorktreeModalContent({
- visible,
- client,
- hostId,
- existingWorktreePaths,
- existingWorktrees,
- onCreated,
- onClose
-}: Props) {
- const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
- const [repos, setRepos] = useState(initialRepos ?? [])
- const [selectedRepo, setSelectedRepo] = useState(null)
- // Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so
- // its name must never be suggested again. Fetched per selected repo while the sheet is open.
- // Keyed on the path set rather than the array so a poll that changes nothing does not refetch.
+function NewWorktreeModalContent(props: NewWorktreeModalProps) {
+ const { visible, client, hostId, existingWorktreePaths, existingWorktrees, onCreated, onClose } =
+ props
+ const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({
+ client,
+ hostId,
+ visible
+ })
+ const navigation = useNewWorktreeDrawerNavigation(visible)
+ const [note, setNote] = useState('')
+ const [error, setError] = useState('')
+ const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId)
+ const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
+ useNewWorktreeRuntimeCapabilities(client, visible)
+ const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
+ const executionTarget = useNewWorkspaceExecutionTarget({
+ client,
+ connectionId: selectedRepoConnectionId,
+ visible
+ })
+ const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo })
+ const selectedRepoWorktreeBranches = useMemo(
+ () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
+ [existingWorktrees, selectedRepo]
+ )
+ const composer = useMobileComposerSource({
+ client,
+ selectedRepoId: selectedRepo?.id ?? null,
+ worktreeBranches: selectedRepoWorktreeBranches,
+ onError: setError
+ })
+ const agentSelection = useNewWorkspaceAgentSelection({
+ visible,
+ runtimeSettings: runtime.runtimeSettings,
+ detectedAgentIds: executionTarget.detectedAgentIds
+ })
const retiredNamesRefreshKey = useMemo(
- () => [...(existingWorktreePaths ?? [])].sort().join('\0'),
+ () => buildRetiredWorktreeNamesRefreshKey(existingWorktreePaths),
[existingWorktreePaths]
)
const retiredWorktreeNames = useRetiredWorktreeNames(
@@ -211,90 +100,42 @@ function NewWorktreeModalContent({
selectedRepo?.id,
retiredNamesRefreshKey
)
- const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
- useNewWorktreeDrawerNavigation(visible)
- const createInFlightRef = useRef(false)
- const setupTrustActionInFlightRef = useRef(false)
- const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!)
- const [runtimeSettings, setRuntimeSettings] = useState(null)
- const [detectedAgentIdsState, setDetectedAgentIdsState] = useState(
- null
- )
- const [agentOverriddenState, setAgentOverridden] = useState(false)
- const [sshState, setSshState] = useState(null)
- const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null)
- const [note, setNote] = useState('')
- const [availableProviders, setAvailableProviders] = useState([])
- const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
- useNewWorktreeRuntimeCapabilities(client, visible)
- const [showAdvanced, setShowAdvanced] = useState(false)
- const [setupHookDetails, setSetupHookDetails] = useState(null)
- const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({})
- const [setupTrustPrompt, setSetupTrustPrompt] = useState(null)
- const [setupDecisionChoice, setSetupDecisionChoice] = useState | null>(null)
- const [runSetup, setRunSetup] = useState(true)
- const [creating, setCreating] = useState(false)
- const [error, setError] = useState('')
- const [loading, setLoading] = useState(initialRepos == null)
- const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
- const selectedRepoWorktreeBranches = useMemo(
- () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
- [existingWorktrees, selectedRepo]
- )
-
- const composer = useMobileComposerSource({
+ const createSubmit = useNewWorkspaceCreateSubmit({
client,
- selectedRepoId: selectedRepo?.id ?? null,
- worktreeBranches: selectedRepoWorktreeBranches,
- onError: setError
+ selectedRepo,
+ selectedAgent: agentSelection.selectedAgent,
+ setSelectedAgent: agentSelection.setSelectedAgent,
+ setAgentOverridden: agentSelection.setAgentOverridden,
+ runtimeSettings: runtime.runtimeSettings,
+ setRuntimeSettings: runtime.setRuntimeSettings,
+ detectedAgentIds: executionTarget.detectedAgentIds,
+ sshGate: executionTarget.sshGate,
+ composer,
+ note,
+ existingWorktreePaths,
+ retiredWorktreeNames,
+ setupCommand: setupScript.setupCommand,
+ setupTrust: setupScript.setupTrust,
+ setupRunPolicy: setupScript.setupRunPolicy,
+ setupDecisionChoice: setupScript.setupDecisionChoice,
+ runSetup: setupScript.runSetup,
+ trustedOrcaHooks: runtime.trustedOrcaHooks,
+ setTrustedOrcaHooks: runtime.setTrustedOrcaHooks,
+ getWorktreeCreateCutoverSupport,
+ transitionDrawer: navigation.transitionDrawer,
+ setError,
+ onCreated,
+ onClose
})
- const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
- const sshGate = deriveWorkspaceSshGate({
- connectionId: selectedRepoConnectionId,
- state: sshState,
- connecting: sshConnectingTargetId === selectedRepoConnectionId
- })
- const detectedAgentIds =
- detectedAgentIdsState?.connectionId === selectedRepoConnectionId &&
- (selectedRepoConnectionId === null || sshGate.status === 'connected')
- ? detectedAgentIdsState.ids
- : null
- const activeSetupHookDetails =
- selectedRepo && setupHookDetails?.repoId === selectedRepo.id ? setupHookDetails : null
- const setupCommand = activeSetupHookDetails?.command ?? null
- const setupSource = activeSetupHookDetails?.source ?? null
- const setupTrust = activeSetupHookDetails?.trust ?? null
- const setupRunPolicy = activeSetupHookDetails?.runPolicy ?? 'run-by-default'
- const selectedAgentResolution = resolveNewWorktreeAgentSelection({
- visible,
- selectedAgent: selectedAgentState,
- agentOverridden: agentOverriddenState,
- runtimeSettings,
- detectedAgentIds
- })
- // Why: agent preference repair is pure render dataflow; doing it here
- // avoids a stale selected-agent commit while preserving user overrides.
- if (
- selectedAgentState.id !== selectedAgentResolution.selectedAgent.id ||
- agentOverriddenState !== selectedAgentResolution.agentOverridden
- ) {
- setSelectedAgent(selectedAgentResolution.selectedAgent)
- setAgentOverridden(selectedAgentResolution.agentOverridden)
- }
- const selectedAgent = selectedAgentResolution.selectedAgent
-
const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true
const sourceAvailability: SmartModeAvailabilityInput = {
textOnly: selectedRepo != null && !selectedRepoIsGit,
tasksSupported,
hasRepo: selectedRepo != null,
- githubAvailable: availableProviders.includes('github'),
- gitlabAvailable: availableProviders.includes('gitlab'),
- linearAvailable: availableProviders.includes('linear')
+ githubAvailable: runtime.availableProviders.includes('github'),
+ gitlabAvailable: runtime.availableProviders.includes('gitlab'),
+ linearAvailable: runtime.availableProviders.includes('linear')
}
const pasteRepos = useMemo(
() =>
@@ -305,423 +146,6 @@ function NewWorktreeModalContent({
})),
[repos]
)
-
- useEffect(() => {
- if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
- return
- }
- const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos)
- const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({
- eligibleRepos,
- activeRepoId: lastVisitedRepo.repoId
- })
- const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null
- if (preferredRepo) {
- setSelectedRepo(preferredRepo)
- }
- }, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
-
- useEffect(() => {
- if (!visible || !client) {
- return
- }
- let stale = false
-
- if (repos.length === 0) {
- setLoading(true)
- }
-
- void client
- .sendRequest('repo.list')
- .then((repoResponse) => {
- if (stale) {
- return
- }
- if (repoResponse.ok) {
- const result = (repoResponse as RpcSuccess).result as { repos: Repo[] }
- setRepos(result.repos)
- if (hostId) {
- setCachedRepos(hostId, result.repos)
- }
- setSelectedRepo((current) => {
- // Why: the optimistic cache can include repos removed before the
- // fresh repo.list returns; never create against a stale repo id.
- return refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current)
- })
- }
- })
- // Why (F10): a dropped connection rejects this call — keep the last-good list (the content
- // remounts with the new host's cache when the client changes) instead of emptying the picker.
- .catch(() => undefined)
- .finally(() => {
- if (!stale) {
- setLoading(false)
- }
- })
-
- void (async () => {
- // Why: settle each RPC independently so a flaky availability probe (e.g. a
- // linear.status timeout, which rejects rather than resolving {ok:false})
- // can't discard the already-resolved critical settings/ui results.
- const probes = Promise.allSettled([
- client.sendRequest('preflight.check'),
- client.sendRequest('linear.status')
- ])
- const okResult = (entry: PromiseSettledResult): RpcSuccess | null =>
- entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
- // Why: hydrate settings/trust the moment their own RPCs settle — gating them
- // on the probes (a first-open preflight.check can take seconds) widens the
- // window where an already-trusted setup hook spuriously re-prompts on create.
- const [settingsRes, uiRes] = await Promise.allSettled([
- client.sendRequest('settings.get'),
- client.sendRequest('ui.get')
- ])
- if (stale) {
- return
- }
-
- const settingsResult = okResult(settingsRes)
- const settingsValue = settingsResult
- ? (
- settingsResult.result as {
- settings: RuntimeSettings & { visibleTaskProviders?: unknown }
- }
- ).settings
- : null
- if (settingsValue) {
- setRuntimeSettings(settingsValue)
- }
- const uiResult = okResult(uiRes)
- if (uiResult) {
- const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
- setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
- }
-
- const [preflightRes, linearRes] = await probes
- if (stale) {
- return
- }
- const glabInstalled =
- (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab
- ?.installed === true
- const linearConnected =
- (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true
- const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
- setAvailableProviders(
- // Drop filterAvailableTaskProviders' forced 'github' fallback when the user
- // hid GitHub; the Branch tab always guarantees at least one tab remains.
- filterAvailableTaskProviders(visibleProviders, {
- gitlabInstalled: glabInstalled,
- linearConnected
- }).filter((provider) => visibleProviders.includes(provider))
- )
- })()
- return () => {
- stale = true
- }
- }, [visible, client, hostId])
-
- useEffect(() => {
- if (!visible || !client || !selectedRepoConnectionId) {
- return
- }
- let stale = false
- void client
- .sendRequest('ssh.getState', { targetId: selectedRepoConnectionId })
- .then((response) => {
- if (stale) {
- return
- }
- if (!response.ok) {
- throw new Error(response.error.message)
- }
- const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
- setSshState(
- state.state ?? {
- targetId: selectedRepoConnectionId,
- status: 'disconnected',
- error: null,
- reconnectAttempt: 0
- }
- )
- })
- .catch((err) => {
- if (!stale) {
- setSshState({
- targetId: selectedRepoConnectionId,
- status: 'error',
- error: err instanceof Error ? err.message : 'Failed to read SSH connection state.',
- reconnectAttempt: 0
- })
- }
- })
- return () => {
- stale = true
- }
- }, [client, selectedRepoConnectionId, visible])
-
- useEffect(() => {
- if (!visible || !client) {
- return
- }
- if (selectedRepoConnectionId && sshGate.status !== 'connected') {
- return
- }
- let stale = false
- void (async () => {
- try {
- const response = selectedRepoConnectionId
- ? await client.sendRequest('preflight.detectRemoteAgents', {
- connectionId: selectedRepoConnectionId
- })
- : await client.sendRequest('preflight.detectAgents')
- if (stale) {
- return
- }
- setDetectedAgentIdsState({
- connectionId: selectedRepoConnectionId,
- ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
- })
- } catch {
- if (!stale) {
- setDetectedAgentIdsState({ connectionId: selectedRepoConnectionId, ids: new Set() })
- }
- }
- })()
- return () => {
- stale = true
- }
- }, [client, selectedRepoConnectionId, sshGate.status, visible])
-
- useEffect(() => {
- if (!client || !selectedRepo) {
- return
- }
- let stale = false
- void (async () => {
- try {
- const response = await client.sendRequest('repo.hooks', {
- repo: `id:${selectedRepo.id}`
- })
- if (stale) {
- return
- }
- if (response.ok) {
- const result = (response as RpcSuccess).result as RepoHooksResponse
- const cmd = result.hooks?.scripts?.setup?.trim() || null
- const policy = result.setupRunPolicy ?? 'run-by-default'
- setSetupHookDetails({
- repoId: selectedRepo.id,
- command: cmd,
- source: result.source,
- trust: normalizeSetupHookTrust(result.setupTrust),
- runPolicy: policy
- })
- setSetupDecisionChoice(null)
- setRunSetup(policy !== 'skip-by-default')
- if (cmd && policy === 'ask') {
- setShowAdvanced(true)
- }
- }
- } catch {
- if (!stale) {
- setSetupHookDetails({
- repoId: selectedRepo.id,
- command: null,
- source: null,
- trust: null,
- runPolicy: 'run-by-default'
- })
- setSetupDecisionChoice(null)
- }
- }
- })()
- return () => {
- stale = true
- }
- }, [client, selectedRepo])
-
- async function connectSelectedSshRepo(): Promise {
- if (!client || !selectedRepoConnectionId) {
- return
- }
- setSshConnectingTargetId(selectedRepoConnectionId)
- setSshState({
- targetId: selectedRepoConnectionId,
- status: 'connecting',
- error: null,
- reconnectAttempt: 0
- })
- try {
- const response = await client.sendRequest(
- 'ssh.connect',
- { targetId: selectedRepoConnectionId },
- { timeoutMs: 120_000 }
- )
- if (!response.ok) {
- throw new Error(response.error.message)
- }
- const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
- setSshState(
- result.state ?? {
- targetId: selectedRepoConnectionId,
- status: 'connected',
- error: null,
- reconnectAttempt: 0
- }
- )
- } catch (err) {
- setSshState({
- targetId: selectedRepoConnectionId,
- status: 'error',
- error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.',
- reconnectAttempt: 0
- })
- } finally {
- setSshConnectingTargetId((current) => (current === selectedRepoConnectionId ? null : current))
- }
- }
-
- async function handleCreate(options: CreateOptions = {}) {
- if (!client || !selectedRepo || createInFlightRef.current) {
- return
- }
- createInFlightRef.current = true
- setCreating(true)
- setError('')
-
- try {
- if (sshGate.requiresConnection) {
- setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
- return
- }
- let latestRuntimeSettings = runtimeSettings
- try {
- const settingsResponse = await client.sendRequest('settings.get')
- if (settingsResponse.ok) {
- const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings }
- latestRuntimeSettings = result.settings
- setRuntimeSettings(result.settings)
- }
- } catch {
- // Best-effort refresh; the runtime validates the same setting before spawning.
- }
- if (
- selectedAgent.id !== '__blank__' &&
- !isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
- ) {
- setSelectedAgent(pickPreferredNewWorktreeAgent(latestRuntimeSettings, detectedAgentIds))
- setAgentOverridden(false)
- setError('Selected agent is disabled. Choose an enabled agent before creating.')
- return
- }
-
- // Why: blank name field — match desktop behavior by computing the
- // next available marine-creature name at submit time and passing it
- // to the server. The server's worktree.create rejects empty/invalid
- // names, so we must generate one client-side rather than letting the
- // server invent one. The pre-flight basename dedupe is only a hint;
- // the authoritative collision is checked server-side against git
- // branches/remotes/PRs, so we also retry-with-suffix on conflict.
- const trimmedName = composer.name.trim()
- const baseName =
- trimmedName ||
- getSuggestedCreatureName(existingWorktreePaths ?? [], undefined, retiredWorktreeNames)
-
- let setupDecision: SetupDecision = 'inherit'
- if (setupCommand) {
- if (options.setupOverride) {
- setupDecision = options.setupOverride
- } else if (setupRunPolicy === 'ask') {
- if (!setupDecisionChoice) {
- setError('Choose whether to run the setup script.')
- return
- }
- setupDecision = setupDecisionChoice
- } else {
- setupDecision = runSetup ? 'run' : 'skip'
- }
- }
- if (
- setupDecision === 'run' &&
- setupTrust &&
- setupTrust.contentHash !== options.approvedSetupContentHash &&
- !isSetupHookTrusted(trustedOrcaHooks, selectedRepo.id, setupTrust.contentHash)
- ) {
- // Why: desktop prompts before running repo-owned orca.yaml setup hooks.
- // Mobile stores the same trust hash so approvals carry across surfaces.
- setSetupTrustPrompt({
- repoId: selectedRepo.id,
- repoName: selectedRepo.displayName,
- scriptContent: setupTrust.scriptContent,
- contentHash: setupTrust.contentHash,
- previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
- })
- transitionDrawer('trust')
- return
- }
-
- const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined
- const trimmedNote = note.trim() || undefined
- const createSelection = composer.createSelection
- const result = createSelection
- ? await createWorkspaceFromComposerSource({
- client,
- selection: createSelection,
- targetRepoId: selectedRepo.id,
- setupDecision,
- agent: { choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank' },
- workspaceName: trimmedName || undefined,
- note: trimmedNote,
- nameIsAutoManaged: composer.isNameAutoManaged,
- supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport()
- })
- : await createBlankWorkspace({
- client,
- repoId: selectedRepo.id,
- baseName,
- // `baseName` is the suggestion exactly when the user typed nothing, so no identity check
- // is needed — desktop's seeded composer needs one (useComposerState `nameWasGenerated`).
- nameWasGenerated: !trimmedName,
- createdWithAgentId,
- comment: trimmedNote,
- setupDecision,
- supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport()
- })
- if ('error' in result) {
- setError(result.error)
- return
- }
- onClose()
- onCreated(result.worktreeId, result.name)
- } catch (e) {
- setError(e instanceof Error ? e.message : 'Failed to create workspace')
- } finally {
- createInFlightRef.current = false
- setCreating(false)
- }
- }
-
- const needsSetupChoice = Boolean(setupCommand) && setupRunPolicy === 'ask'
- const canCreate =
- selectedRepo != null &&
- !creating &&
- !sshGate.requiresConnection &&
- (!needsSetupChoice || setupDecisionChoice != null)
- const visibleAgentOptions =
- detectedAgentIds === null
- ? AGENT_OPTIONS.filter(
- (agent) =>
- agent.id !== '__blank__' &&
- isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
- )
- : AGENT_OPTIONS.filter(
- (agent) =>
- agent.id !== '__blank__' &&
- detectedAgentIds.has(agent.id) &&
- isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
- )
- const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const selectedProject =
@@ -733,604 +157,108 @@ function NewWorktreeModalContent({
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
: null
+ const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask'
+ const canCreate =
+ selectedRepo != null &&
+ !createSubmit.creating &&
+ !executionTarget.sshGate.requiresConnection &&
+ (!needsSetupChoice || setupScript.setupDecisionChoice != null)
- function prepareSelectionPickerOpen(): void {
- // Why: picker taps can beat an open soft keyboard; dismissing it prevents the
- // keyboard from reopening under the picker drawer.
+ function openPicker(view: 'project' | 'runTarget' | 'agent'): void {
Keyboard.dismiss()
+ navigation.transitionDrawer(view)
}
- function handleRepoSelected(repo: Repo): void {
+ function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void {
const repoChanged = repo.id !== selectedRepo?.id
setSelectedRepo(repo)
- // Branch and provider-backed sources are repo-scoped; Linear/Jira are global
- // work context and survive choosing a different implementation repo.
- if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) {
+ if (
+ clearRepoScopedSource &&
+ repoChanged &&
+ !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)
+ ) {
composer.handleClearSmartNameSelection()
}
}
- async function approveSetupTrust(alwaysTrust: boolean): Promise {
- if (
- !client ||
- !setupTrustPrompt ||
- setupTrustActionInFlightRef.current ||
- createInFlightRef.current
- ) {
- return
+ function requestClose(): void {
+ if (navigation.drawerView === 'form') {
+ onClose()
+ } else if (navigation.drawerView === 'trust') {
+ createSubmit.closeSetupTrust()
+ } else {
+ navigation.transitionDrawer('form')
}
- setupTrustActionInFlightRef.current = true
- setCreating(true)
- try {
- const nextTrust = await persistSetupHookTrustApproval({
- client,
- trust: trustedOrcaHooks,
- repoId: setupTrustPrompt.repoId,
- contentHash: setupTrustPrompt.contentHash,
- alwaysTrust
- })
- setTrustedOrcaHooks(nextTrust)
- const approvedHash = setupTrustPrompt.contentHash
- setSetupTrustPrompt(null)
- transitionDrawer('form')
- await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
- } finally {
- setupTrustActionInFlightRef.current = false
- if (!createInFlightRef.current) {
- setCreating(false)
- }
- }
- }
-
- function closeSetupTrust(): void {
- if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
- return
- }
- setSetupTrustPrompt(null)
- transitionDrawer('form')
- }
-
- function skipSetupTrust(): void {
- if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
- return
- }
- closeSetupTrust()
- void handleCreate({ setupOverride: 'skip' })
}
return (
- // Why: hosting the form and every picker in one persistent native Modal makes
- // form → repo/agent transitions in-window view swaps, avoiding the iOS
- // dismiss-then-present race that left the dropdowns unresponsive. Native back
- // closes the flow from the form, routes the trust prompt through its in-flight
- // guard, and otherwise returns to the form from a picker.
- {
- if (drawerView === 'form') {
- onClose()
- } else if (drawerView === 'trust') {
- closeSetupTrust()
- } else {
- transitionDrawer('form')
- }
- }}
- >
-
-
- Create worktree
-
+
+ 0}
+ project={selectedProject}
+ runTarget={selectedRunTarget}
+ projectBadgeColor={selectedRepo ? getMobileWorkspaceRepoBadgeColor(selectedRepo) : null}
+ selectedRepoIsGit={selectedRepoIsGit}
+ selectedRepoConnectionId={selectedRepoConnectionId}
+ selectedRepoName={selectedRepo?.displayName ?? 'Remote repository'}
+ sshGate={executionTarget.sshGate}
+ composer={composer}
+ selectedAgent={agentSelection.selectedAgent}
+ showAdvanced={setupScript.showAdvanced}
+ note={note}
+ setupCommand={setupScript.setupCommand}
+ setupSource={setupScript.setupSource}
+ setupRunPolicy={setupScript.setupRunPolicy}
+ setupDecisionChoice={setupScript.setupDecisionChoice}
+ runSetup={setupScript.runSetup}
+ error={error}
+ creating={createSubmit.creating}
+ canCreate={canCreate}
+ onClose={onClose}
+ onOpenProject={() => openPicker('project')}
+ onOpenRunTarget={() => openPicker('runTarget')}
+ onOpenSource={navigation.openSourceDrawer}
+ onClearError={() => setError('')}
+ onConnect={() => void executionTarget.connect()}
+ onOpenAgent={() => openPicker('agent')}
+ onShowAdvancedChange={setupScript.setShowAdvanced}
+ onNoteChange={setNote}
+ onSetupDecisionChange={setupScript.setSetupDecisionChoice}
+ onRunSetupChange={setupScript.setRunSetup}
+ onCreate={() => void createSubmit.create()}
+ />
- {loading ? (
-
-
-
- ) : repos.length === 0 ? (
-
- No projects found
-
- ) : (
- <>
- {
- prepareSelectionPickerOpen()
- transitionDrawer('project')
- }}
- onOpenRunTarget={() => {
- prepareSelectionPickerOpen()
- transitionDrawer('runTarget')
- }}
- />
-
- setError('')}
- onOpenDrawer={openSourceDrawer}
- />
-
- {composer.forkPushWarning ? (
- {composer.forkPushWarning}
- ) : null}
-
- {selectedRepoConnectionId ? (
-
- SSH Connection
-
-
-
-
-
- {selectedRepo?.displayName ?? 'Remote repository'}
-
-
- {workspaceSshStatusLabel(sshGate.status)}
-
-
- {sshGate.status === 'connected' ? null : (
- void connectSelectedSshRepo()}
- >
-
- {sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
-
-
- )}
-
- {sshGate.error ? {sshGate.error} : null}
-
-
- ) : null}
-
-
- Agent
- {
- prepareSelectionPickerOpen()
- transitionDrawer('agent')
- }}
- >
-
-
- {sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
-
-
-
-
-
- setShowAdvanced(!showAdvanced)}>
- Advanced
- {showAdvanced ? (
-
- ) : (
-
- )}
-
-
- {showAdvanced && (
- <>
-
-
-
- Note
-
-
-
- {setupCommand ? (
-
-
- Setup script
- {setupSource && (
-
-
- {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
-
-
- )}
-
-
- {setupRunPolicy === 'ask' ? (
-
- setSetupDecisionChoice('run')}
- >
- Run
-
- setSetupDecisionChoice('skip')}
- >
- Skip
-
-
- ) : (
-
- Run setup command
-
-
- )}
-
- {setupCommand}
-
-
-
- ) : null}
- >
- )}
-
- {error ? {error} : null}
-
-
- void handleCreate()}
- >
- {creating ? (
-
- ) : (
-
- {sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
-
- )}
-
-
- >
- )}
-
-
- {/* Why: list drawers stay outside the form's ScrollView, and the transition
- state lets each hosted overlay finish hiding before the next appears. */}
- {
- const nextRepo = repos.find((repo) => repo.id === repoId)
- if (nextRepo) {
- setSelectedRepo(nextRepo)
- }
+ sourceAvailability={sourceAvailability}
+ selectedRepo={selectedRepo}
+ repos={repos}
+ pasteRepos={pasteRepos}
+ sshReady={!executionTarget.sshGate.requiresConnection}
+ projectPickerItems={projectPickerItems}
+ selectedProjectId={selectedProjectId}
+ runTargetPickerItems={runTargetPickerItems}
+ pickerAgentOptions={agentSelection.pickerAgentOptions}
+ selectedAgent={agentSelection.selectedAgent}
+ setupTrustPrompt={createSubmit.setupTrustPrompt}
+ creating={createSubmit.creating}
+ onSourceRepoChange={(repo) => selectRepo(repo, false)}
+ onRepoChange={(repo) => selectRepo(repo, true)}
+ onAgentChange={(agent) => {
+ agentSelection.setAgentOverridden(true)
+ agentSelection.setSelectedAgent(agent)
}}
- onClose={() => transitionDrawer('form')}
- />
-
- handleRepoSelected(item.repo)}
- onClose={() => transitionDrawer('form')}
- renderIcon={(item) => {
- return
- }}
- />
-
- handleRepoSelected(item.repo)}
- onClose={() => transitionDrawer('form')}
- renderIcon={() => }
- />
-
- {
- setAgentOverridden(true)
- setSelectedAgent(agent)
- }}
- onClose={() => transitionDrawer('form')}
- renderIcon={(agent) => }
- />
-
- void approveSetupTrust(false)}
- onAlwaysTrust={() => void approveSetupTrust(true)}
- onDontRun={skipSetupTrust}
- onClose={closeSetupTrust}
+ onTransitionToForm={() => navigation.transitionDrawer('form')}
+ onApproveSetupTrust={(alwaysTrust) => void createSubmit.approveSetupTrust(alwaysTrust)}
+ onSkipSetupTrust={createSubmit.skipSetupTrust}
+ onCloseSetupTrust={createSubmit.closeSetupTrust}
/>
)
}
-
-const styles = StyleSheet.create({
- header: {
- paddingHorizontal: spacing.xs,
- marginBottom: spacing.md
- },
- title: {
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- loadingContainer: {
- paddingVertical: spacing.xl,
- alignItems: 'center'
- },
- emptyText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize
- },
- field: {
- marginBottom: spacing.md
- },
- label: {
- fontSize: 13,
- fontWeight: '500',
- color: colors.textSecondary,
- marginBottom: spacing.xs
- },
- labelHint: {
- fontWeight: '400',
- color: colors.textMuted
- },
- fieldButton: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm,
- backgroundColor: colors.bgRaised,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- fieldButtonText: {
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- fieldButtonPlaceholder: {
- color: colors.textMuted
- },
- repoDot: {
- width: 8,
- height: 8,
- borderRadius: 999
- },
- disabled: {
- opacity: 0.55
- },
- sshBox: {
- backgroundColor: colors.bgRaised,
- borderRadius: radii.input,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- gap: spacing.xs
- },
- sshRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- sshDot: {
- width: 8,
- height: 8,
- borderRadius: 999
- },
- sshDotConnected: {
- backgroundColor: colors.statusGreen
- },
- sshDotProgress: {
- backgroundColor: colors.statusAmber
- },
- sshDotDisconnected: {
- backgroundColor: colors.statusRed
- },
- sshCopy: {
- flex: 1,
- minWidth: 0
- },
- sshTitle: {
- fontSize: typography.bodySize,
- color: colors.textPrimary,
- fontWeight: '600'
- },
- sshSubtitle: {
- fontSize: 12,
- color: colors.textSecondary,
- marginTop: 1
- },
- sshConnectButton: {
- borderRadius: radii.button,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- sshConnectText: {
- color: colors.textPrimary,
- fontSize: 12,
- fontWeight: '600'
- },
- errorInline: {
- color: colors.statusRed,
- fontSize: 12
- },
- input: {
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
- fontSize: typography.bodySize,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- error: {
- color: colors.statusRed,
- fontSize: 13,
- marginBottom: spacing.md
- },
- sourceWarning: {
- marginTop: -spacing.sm,
- marginBottom: spacing.md,
- fontSize: 12,
- color: colors.statusAmber
- },
- advancedToggle: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- paddingVertical: spacing.sm,
- marginBottom: spacing.xs
- },
- advancedText: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textSecondary
- },
- setupHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: spacing.xs
- },
- sourceBadge: {
- backgroundColor: colors.bgRaised,
- borderRadius: 4,
- paddingHorizontal: spacing.xs + 2,
- paddingVertical: 2
- },
- sourceBadgeText: {
- fontSize: 10,
- fontWeight: '600',
- color: colors.textMuted,
- letterSpacing: 0.5
- },
- setupBox: {
- backgroundColor: colors.bgRaised,
- borderRadius: radii.input,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- padding: spacing.md
- },
- setupToggleRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: spacing.sm
- },
- setupToggleLabel: {
- fontSize: 13,
- color: colors.textSecondary
- },
- setupChoiceRow: {
- flexDirection: 'row',
- gap: spacing.sm,
- marginBottom: spacing.sm
- },
- setupChoiceButton: {
- flex: 1,
- alignItems: 'center',
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingVertical: spacing.sm
- },
- setupChoiceButtonSelected: {
- backgroundColor: colors.bgPanel,
- borderColor: colors.textSecondary
- },
- setupChoiceText: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary
- },
- setupSwitch: {
- transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
- },
- setupCommandBlock: {
- backgroundColor: colors.bgBase,
- borderRadius: 6,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.sm
- },
- setupCommand: {
- fontSize: 13,
- fontFamily: typography.monoFamily,
- color: colors.textPrimary
- },
- actions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginTop: spacing.sm
- },
- createButton: {
- backgroundColor: colors.textPrimary,
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- borderRadius: radii.button,
- minWidth: 160,
- alignItems: 'center'
- },
- createButtonDisabled: {
- opacity: 0.4
- },
- createText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- }
-})
diff --git a/mobile/src/components/NewWorktreeModalDrawers.tsx b/mobile/src/components/NewWorktreeModalDrawers.tsx
new file mode 100644
index 00000000000..70ce22cb807
--- /dev/null
+++ b/mobile/src/components/NewWorktreeModalDrawers.tsx
@@ -0,0 +1,115 @@
+import { View } from 'react-native'
+import { Monitor } from 'lucide-react-native'
+import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
+import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
+import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import { colors } from '../theme/mobile-theme'
+import { MobileAgentIcon } from './MobileAgentIcon'
+import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
+import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
+import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
+import type {
+ NewWorkspaceProjectOption,
+ NewWorkspaceRunTargetOption
+} from './new-workspace-project-targets'
+import { getMobileWorkspaceRepoBadgeColor } from './new-worktree-modal-types'
+import { PickerListDrawer } from './PickerListDrawer'
+import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
+import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
+import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
+
+type Composer = ReturnType
+
+export function NewWorktreeModalDrawers(props: {
+ visible: boolean
+ drawerView: NewWorktreeDrawerView
+ client: Parameters[0]['client']
+ composer: Composer
+ sourceAvailability: SmartModeAvailabilityInput
+ selectedRepo: MobileWorkspaceRepo | null
+ repos: MobileWorkspaceRepo[]
+ pasteRepos: PasteRepoCandidate[]
+ sshReady: boolean
+ projectPickerItems: NewWorkspaceProjectOption[]
+ selectedProjectId: string | null
+ runTargetPickerItems: NewWorkspaceRunTargetOption[]
+ pickerAgentOptions: NewWorktreeAgentOption[]
+ selectedAgent: NewWorktreeAgentOption
+ setupTrustPrompt: SetupTrustPrompt | null
+ creating: boolean
+ onSourceRepoChange: (repo: MobileWorkspaceRepo) => void
+ onRepoChange: (repo: MobileWorkspaceRepo) => void
+ onAgentChange: (agent: NewWorktreeAgentOption) => void
+ onTransitionToForm: () => void
+ onApproveSetupTrust: (alwaysTrust: boolean) => void
+ onSkipSetupTrust: () => void
+ onCloseSetupTrust: () => void
+}) {
+ return (
+ <>
+ {
+ const nextRepo = props.repos.find((repo) => repo.id === repoId)
+ if (nextRepo) {
+ props.onSourceRepoChange(nextRepo)
+ }
+ }}
+ onClose={props.onTransitionToForm}
+ />
+
+ props.onRepoChange(item.repo)}
+ onClose={props.onTransitionToForm}
+ renderIcon={(item) => (
+
+ )}
+ />
+
+ props.onRepoChange(item.repo)}
+ onClose={props.onTransitionToForm}
+ renderIcon={() => }
+ />
+
+ }
+ />
+
+ props.onApproveSetupTrust(false)}
+ onAlwaysTrust={() => props.onApproveSetupTrust(true)}
+ onDontRun={props.onSkipSetupTrust}
+ onClose={props.onCloseSetupTrust}
+ />
+ >
+ )
+}
diff --git a/mobile/src/components/StatusDot.test.ts b/mobile/src/components/StatusDot.test.ts
new file mode 100644
index 00000000000..fd768a05867
--- /dev/null
+++ b/mobile/src/components/StatusDot.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('react-native', () => ({
+ StyleSheet: { create: (styles: T) => styles },
+ View: 'View'
+}))
+
+import { colors } from '../theme/mobile-theme'
+import { statusDotColor } from './StatusDot'
+
+describe('statusDotColor', () => {
+ it('keeps Relay progress amber when the raw state is disconnected', () => {
+ expect(statusDotColor('disconnected', { kind: 'normal', label: 'Connecting via Relay…' })).toBe(
+ colors.statusAmber
+ )
+ })
+
+ it('keeps an idle disconnected host gray', () => {
+ expect(statusDotColor('disconnected', { kind: 'normal', label: 'Disconnected' })).toBe(
+ colors.textMuted
+ )
+ })
+})
diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx
index a29b5e95eba..f9514b4e0bf 100644
--- a/mobile/src/components/StatusDot.tsx
+++ b/mobile/src/components/StatusDot.tsx
@@ -12,6 +12,16 @@ const stateColors: Record = {
'auth-failed': colors.statusRed
}
+export function statusDotColor(state: ConnectionState, verdict?: ConnectionVerdict): string {
+ if (verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed') {
+ return colors.statusRed
+ }
+ if (verdict?.kind === 'warning' || (verdict?.kind === 'normal' && verdict.label.endsWith('…'))) {
+ return colors.statusAmber
+ }
+ return stateColors[state] ?? colors.textMuted
+}
+
// Why: when caller passes a verdict, the dot color reflects the verdict's
// severity instead of the raw transport state. This avoids the "amber dot
// next to red 'Can't reach desktop' label" mismatch — the underlying
@@ -24,12 +34,7 @@ export function StatusDot({
state: ConnectionState
verdict?: ConnectionVerdict
}) {
- const color =
- verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
- ? colors.statusRed
- : verdict?.kind === 'warning'
- ? colors.statusAmber
- : (stateColors[state] ?? colors.textMuted)
+ const color = statusDotColor(state, verdict)
return
}
diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts
index d0825412636..01e0128cdc1 100644
--- a/mobile/src/components/WorktreeListRow.test.ts
+++ b/mobile/src/components/WorktreeListRow.test.ts
@@ -199,7 +199,7 @@ describe('memoized worktree rows', () => {
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
- agent: agent({ state: 'done', updatedAt: 2_000 }),
+ agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
@@ -207,5 +207,22 @@ describe('memoized worktree rows', () => {
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
+ expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
+ })
+
+ it('passes workspace monitoring mode to the status indicator', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement(ListRowHarness, {
+ item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
+ now: 2_000
+ })
+ )
+ })
+
+ expect(agentSpinnerRender).toHaveBeenLastCalledWith({
+ status: 'working',
+ workingMode: 'monitoring'
+ })
})
})
diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx
index 74b716c2b30..ba1c3865fc7 100644
--- a/mobile/src/components/WorktreeListRow.tsx
+++ b/mobile/src/components/WorktreeListRow.tsx
@@ -2,6 +2,7 @@ import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
+import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -39,6 +40,7 @@ export type WorktreeListRowItem = {
lineageChildCount?: number
lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
+ workingMode?: AgentWorkingMode
}
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -97,7 +99,7 @@ function WorktreeListRowComponent({
delayLongPress={400}
>
-
+
{item.unread && (
}
+ }
+ unmount(): void
+}
+
+const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
+ animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ animationTiming: vi.fn(() => ({})),
+ setValue: vi.fn()
+}))
+
+vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
+vi.mock('react-native', () => ({
+ Animated: {
+ Value: function Value() {
+ return { interpolate: vi.fn(() => 'rotation'), setValue }
+ },
+ View: 'AnimatedView',
+ loop: animationLoop,
+ timing: animationTiming
+ },
+ Easing: { linear: 'linear' },
+ StyleSheet: { create: (styles: T) => styles },
+ View: 'View'
+}))
+
+describe('mobile monitoring indicators', () => {
+ let renderer: MonitoringTestRenderer | null = null
+
+ beforeEach(() => {
+ animationLoop.mockClear()
+ animationTiming.mockClear()
+ setValue.mockClear()
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ it('renders a static Radio for a monitoring agent', async () => {
+ await act(async () => {
+ renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
+ })
+
+ expect(renderer?.root.findByType('Radio').props).toMatchObject({
+ color: DESKTOP_WORKING_COLOR,
+ size: 10
+ })
+ expect(animationTiming).not.toHaveBeenCalled()
+ expect(animationLoop).not.toHaveBeenCalled()
+ })
+
+ it('renders a static Radio for an all-monitoring workspace', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
+ )
+ })
+
+ expect(renderer?.root.findByType('Radio').props).toMatchObject({
+ color: DESKTOP_WORKING_COLOR,
+ size: 12
+ })
+ expect(animationTiming).not.toHaveBeenCalled()
+ expect(animationLoop).not.toHaveBeenCalled()
+ })
+
+ it('keeps the spinner fallback when workingMode is absent', async () => {
+ await act(async () => {
+ renderer = create(createElement(AgentSpinner, { status: 'working' }))
+ })
+
+ expect(animationTiming).toHaveBeenCalledOnce()
+ expect(animationLoop).toHaveBeenCalledOnce()
+ })
+})
diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
new file mode 100644
index 00000000000..c4348ad15a2
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
@@ -0,0 +1,279 @@
+export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
+ ';',
+ ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
+ ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
+ ' }',
+ ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
+ ' html, body {',
+ ' width: 100%;',
+ ' min-height: 100%;',
+ ' margin: 0;',
+ ' background: var(--editor-surface);',
+ ' color: var(--foreground);',
+ ' font-family: var(--font-sans);',
+ ' overscroll-behavior: contain;',
+ ' }',
+ ' body { overflow: auto; }',
+ ' #editor {',
+ ' min-height: 100vh;',
+ ' padding: 18px 16px 112px;',
+ ' outline: none;',
+ ' font-size: 14px;',
+ ' line-height: 1.7;',
+ ' word-wrap: break-word;',
+ ' overflow-wrap: anywhere;',
+ ' caret-color: var(--foreground);',
+ ' }',
+ ' #editor[contenteditable="false"] {',
+ ' opacity: 0.78;',
+ ' }',
+ ' #editor:empty::before,',
+ ' #editor p.is-empty:first-child::before {',
+ ' content: attr(data-placeholder);',
+ ' color: var(--muted-foreground);',
+ ' pointer-events: none;',
+ ' }',
+ ' #editor > :first-child { margin-top: 0; }',
+ ' h1, h2, h3, h4, h5, h6 {',
+ ' margin: 1.5em 0 0.5em;',
+ ' font-weight: 600;',
+ ' line-height: 1.3;',
+ ' letter-spacing: 0;',
+ ' }',
+ ' h1 { font-size: 1.85em; font-weight: 700; }',
+ ' h2 { font-size: 1.4em; }',
+ ' h3 { font-size: 1.15em; }',
+ ' p, ul, ol, blockquote { margin: 0.75em 0; }',
+ ' ul, ol { padding-left: 1.5em; }',
+ ' ul { list-style: disc; }',
+ ' ol { list-style: decimal; }',
+ ' li { margin: 0.15em 0; }',
+ ' li > p { margin: 0; }',
+ ' ul[data-type="taskList"] {',
+ ' padding-left: 0;',
+ ' list-style: none;',
+ ' }',
+ ' ul[data-type="taskList"] > li {',
+ ' display: flex;',
+ ' align-items: flex-start;',
+ ' gap: 6px;',
+ ' }',
+ ' ul[data-type="taskList"] > li > label {',
+ ' flex-shrink: 0;',
+ ' display: flex;',
+ ' align-items: center;',
+ ' height: 1.55em;',
+ ' user-select: none;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"] {',
+ ' appearance: none;',
+ ' width: 16px;',
+ ' height: 16px;',
+ ' margin: 0;',
+ ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
+ ' border-radius: 4px;',
+ ' background: transparent;',
+ ' position: relative;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked {',
+ ' background: var(--primary);',
+ ' border-color: var(--primary);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
+ ' content: "";',
+ ' position: absolute;',
+ ' left: 4px;',
+ ' top: 1px;',
+ ' width: 5px;',
+ ' height: 9px;',
+ ' border: solid var(--primary-foreground);',
+ ' border-width: 0 2px 2px 0;',
+ ' transform: rotate(45deg);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
+ ' opacity: 0.65;',
+ ' }',
+ ' ul[data-type="taskList"] > li > div {',
+ ' flex: 1;',
+ ' min-width: 0;',
+ ' }',
+ ' ul[data-type="taskList"] > li[data-checked="true"] > div {',
+ ' text-decoration: line-through;',
+ ' color: var(--muted-foreground);',
+ ' }',
+ ' blockquote {',
+ ' padding: 0.5em 1em;',
+ ' border-left: 3px solid var(--border);',
+ ' border-radius: 0 6px 6px 0;',
+ ' color: var(--muted-foreground);',
+ ' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
+ ' }',
+ ' table {',
+ ' width: 100%;',
+ ' margin: 1em 0;',
+ ' border-collapse: collapse;',
+ ' font-size: 0.95em;',
+ ' }',
+ ' th, td {',
+ ' padding: 8px 14px;',
+ ' border: 1px solid var(--border);',
+ ' text-align: left;',
+ ' vertical-align: top;',
+ ' }',
+ ' th {',
+ ' font-weight: 600;',
+ ' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
+ ' }',
+ ' tr:nth-child(odd) td {',
+ ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
+ ' }',
+ ' code {',
+ ' padding: 0.2em 0.4em;',
+ ' border-radius: 5px;',
+ ' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
+ ' font-size: 0.88em;',
+ ' font-family: var(--font-mono);',
+ ' }',
+ ' pre {',
+ ' margin: 0.75em 0;',
+ ' padding: 14px 18px;',
+ ' border-radius: 8px;',
+ ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' overflow-x: auto;',
+ ' line-height: 1.55;',
+ ' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' font-family: var(--font-mono);',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' pre::before {',
+ ' content: attr(data-language);',
+ ' display: block;',
+ ' min-height: 13px;',
+ ' margin-bottom: 4px;',
+ ' color: var(--muted-foreground);',
+ ' font-size: 11px;',
+ ' text-transform: uppercase;',
+ ' }',
+ ' pre code {',
+ ' padding: 0;',
+ ' border-radius: 0;',
+ ' background: transparent;',
+ ' font-size: 0.92em;',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' hr {',
+ ' margin: 1.5em 0;',
+ ' border: none;',
+ ' border-top: 1px solid var(--border);',
+ ' }',
+ ' a {',
+ ' color: var(--accent-link);',
+ ' text-decoration: underline;',
+ ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
+ ' text-underline-offset: 2px;',
+ ' }',
+ ' img {',
+ ' display: block;',
+ ' max-width: 100%;',
+ ' margin: 0.75em 0;',
+ ' border-radius: 8px;',
+ ' }',
+ ' ',
+ '',
+ '',
+ ' ',
+ ' ',
+ '',
+ '