>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 c5c35191f35..f8ddaae9104 100644
--- a/config/patches/node-pty@1.1.0.patch
+++ b/config/patches/node-pty@1.1.0.patch
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
-@@ -1,13 +1,10 @@
+@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- " {
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'])
+ expect(
+ mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
+ ).toEqual(['darwin-arm64'])
})
})
diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs
index 01045eadfbf..3e43beb359e 100644
--- a/config/scripts/build-windows-process-tree-relay-addon.mjs
+++ b/config/scripts/build-windows-process-tree-relay-addon.mjs
@@ -26,9 +26,11 @@ import {
mkdirSync,
openSync,
readFileSync,
- readSync
+ readSync,
+ writeFileSync
} from 'node:fs'
-import { join, resolve } from 'node:path'
+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, '..', '..')
@@ -67,13 +69,16 @@ function assertPatchApplied() {
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
- if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
+ if (bindingGyp.includes('node_addon_api.gyp')) {
throw new Error(
- 'binding.gyp still uses require("node-addon-api").targets. That path is ' +
- 'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
+ '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(
@@ -83,6 +88,58 @@ function assertPatchApplied() {
}
}
+// 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` {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
+ writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
const log = readFileSync(logPath, 'utf8')
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$/)
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
+ return {
+ dir: '../build/Release/',
+ module: {
+ listJobProcessIds() {},
+ terminateJob() {},
+ assignCurrentProcessToJob() {}
+ }
+ }
}
`
)
@@ -337,6 +349,10 @@ 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
index d989221c11a..a90747513a1 100644
--- a/config/scripts/install-node-dependencies-action.test.mjs
+++ b/config/scripts/install-node-dependencies-action.test.mjs
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
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']
diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs
index 35884c1414a..5ad578fd74a 100644
--- a/config/scripts/node-pty-job-ownership.cjs
+++ b/config/scripts/node-pty-job-ownership.cjs
@@ -1,10 +1,6 @@
'use strict'
-const NODE_PTY_JOB_EXPORTS = [
- 'listJobProcessIds',
- 'terminateJob',
- 'assignCurrentProcessToJob'
-]
+const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
if (platform !== 'win32' || nativeName !== 'conpty') {
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/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index 8ccc864aabb..c51b1c18a5c 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -116,6 +116,19 @@ const NATIVE_RUNTIME_PREFIXES = [
'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',
@@ -222,7 +235,11 @@ export function classifyPrJobs(changedFiles) {
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
])
)
- return { should_run: shouldRun, ...jobs }
+ return {
+ should_run: shouldRun,
+ native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
+ ...jobs
+ }
}
function jobDetector(job) {
@@ -274,6 +291,10 @@ 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)
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index 0ce697d1511..ea83796e6cb 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -207,6 +207,22 @@ describe('per-job path classification', () => {
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
@@ -238,7 +254,7 @@ describe('PR Checks skip wiring', () => {
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
- for (const jobName of ['should_run', ...expensiveJobs]) {
+ for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
`\${{ steps.filter.outputs.${jobName} }}`
)
@@ -250,13 +266,26 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
- it('gates each expensive job on its own classifier output', () => {
- 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(
`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', () => {
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 6aec44fca87..8cd622b300c 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,9 +1,14 @@
-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 as parseYaml } from 'yaml'
-import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
+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 = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -165,7 +170,7 @@ describe('PR E2E gate contract', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
- expect(installFor('build').with).toBeUndefined()
+ 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')
}
@@ -209,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
+ 'src/shared/ssh-',
+ 'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -217,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
+ // 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'
]
@@ -249,6 +270,140 @@ 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')
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 04f570f0094..108a4d5ab10 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'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\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ /^(?: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
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
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')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
- const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
- process.stdout.write(`${JSON.stringify(specs)}\n`)
+ 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 a61244d42d3..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: 8 }, (_, 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([8])
- 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,13 +249,16 @@ 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 ['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')
@@ -316,6 +336,9 @@ describe('PR workflow parallelism', () => {
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@')
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/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/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/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs
index 5a3aa465737..e2e650384ec 100644
--- a/config/scripts/windows-process-tree-gyp-path.test.mjs
+++ b/config/scripts/windows-process-tree-gyp-path.test.mjs
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
-const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
+const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
- it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
- expect(PATCH).toContain(
- `+ " {
+ 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'
)
- const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
- expect(bindingGyp).toContain(ABSOLUTE_GYP)
- expect(bindingGyp).not.toContain("require('node-addon-api').targets")
- expect(
- readFileSync(
- join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
- 'utf8'
- )
- ).toContain(ABSOLUTE_GYP)
+ 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', ABSOLUTE_GYP], {
+ const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs
index 8d4b7d568fd..e1742a43bb0 100644
--- a/config/scripts/windows-pty-native-capability-workflow.test.mjs
+++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs
@@ -50,6 +50,11 @@ describe('packaged Windows PTY native capability routing', () => {
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')
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/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 bf98dbdcb80..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
diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md
index 3ef3fdea37a..bbde9829514 100644
--- a/docs/reference/orcad-operations.md
+++ b/docs/reference/orcad-operations.md
@@ -10,12 +10,12 @@ Design background: `docs/design/shipping-orcad.html` §00c and §04.
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` |
+| | 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
@@ -52,13 +52,13 @@ 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 |
+| 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
@@ -91,14 +91,15 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
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 |
+ | 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
@@ -110,7 +111,7 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
- **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
+ 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.
diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md
index 33a72d68d99..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.
@@ -180,7 +180,7 @@ has to be there before the first snapshot is interpreted, which is earlier than
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;
+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'`
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 11e39b35b7e..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,11 +34,11 @@ 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
@@ -62,7 +65,7 @@ Two guards, and they work together:
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
+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
@@ -90,7 +93,7 @@ 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
+what is already compiled inside it. On a host that _already had_ MSVC Build
Tools 2022 installed, that build still failed:
```
@@ -103,7 +106,7 @@ 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*
+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
@@ -151,14 +154,14 @@ 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.
+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
+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.
@@ -258,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
@@ -283,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.
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/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/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/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 6397f69c549..f823f309630 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,
@@ -47,11 +43,32 @@ import {
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
- type BrowserFrameGeometry,
type BrowserPoint,
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,15 +96,6 @@ type MobileBrowserPaneProps = {
onToast: (message: string, durationMs?: number) => void
}
-type FrameLayer = 0 | 1
-
-type PinchGesture = {
- distance: number
- scale: number
- anchorX: number
- anchorY: number
-}
-
type PanGesture = {
x: number
y: number
@@ -105,17 +113,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
@@ -1268,350 +1266,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/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/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/diagnostics/connection-diagnostics-analysis.test.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts
new file mode 100644
index 00000000000..762d82b10d5
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, it } from 'vitest'
+import {
+ diagnoseConnection,
+ getReportableConnectionIncidentId
+} from './connection-diagnostics-analysis'
+import type { ConnectionLogEntry } from '../transport/types'
+
+function event(message: string, detail?: string): ConnectionLogEntry {
+ return { id: message, ts: 1, level: 'error', message, detail }
+}
+
+describe('diagnoseConnection', () => {
+ it('reports the current healthy path instead of a historical failure', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'connected',
+ activePath: 'relay',
+ entries: [event('WebSocket connect timeout')]
+ })
+ ).toEqual({
+ likelyCause: 'Connection is healthy via Relay.',
+ nextStep: 'No action needed.',
+ reportability: 'none'
+ })
+ })
+
+ it('distinguishes an invalid Relay credential from a transient outage', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ pendingPath: 'relay',
+ entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
+ })
+ ).toEqual({
+ likelyCause: 'Relay rejected the saved resume credential.',
+ nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
+ reportability: 'none'
+ })
+ })
+
+ it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ activePath: 'tailscale',
+ pendingPath: 'relay',
+ entries: [event('WebSocket connect timeout')]
+ })
+ ).toEqual({
+ likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
+ nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
+ reportability: 'none'
+ })
+ })
+
+ it('identifies an authenticated Relay liveness failure', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ activePath: 'relay',
+ entries: [
+ {
+ ...event('Relay health check failed'),
+ code: 'liveness-timeout',
+ path: 'relay'
+ }
+ ]
+ })
+ ).toEqual({
+ likelyCause: 'Relay stopped answering authenticated health checks.',
+ nextStep: 'Orca closed the stale session and started recovery.',
+ reportability: 'orca-relay'
+ })
+ })
+
+ it('separates an active Relay close from a failed Relay dial', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ activePath: 'relay',
+ entries: [
+ {
+ ...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
+ code: 'relay-session-failed',
+ path: 'relay'
+ }
+ ]
+ })
+ ).toEqual({
+ likelyCause: 'The active Relay session closed unexpectedly.',
+ nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
+ reportability: 'orca-relay'
+ })
+ })
+
+ it('marks authenticated Relay liveness failures as safe to send', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ activePath: 'relay',
+ entries: [
+ {
+ ...event('Relay health check failed'),
+ code: 'liveness-timeout',
+ path: 'relay'
+ }
+ ]
+ }).reportability
+ ).toBe('orca-relay')
+ })
+
+ it.each([
+ ['direct timeout', 'connect-timeout', 'tailscale'],
+ ['handshake timeout', 'handshake-timeout', 'relay'],
+ ['invalid credential', 'relay director resolve failed (401)', 'relay'],
+ ['ambiguous recovery', 'retry scheduled', 'relay']
+ ] as const)('does not offer submission for %s', (_name, message, path) => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ pendingPath: 'relay',
+ entries: [{ ...event(message), path }]
+ }).reportability
+ ).toBe('none')
+ })
+
+ it('does not offer submission for a bounded Relay director outage', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ pendingPath: 'relay',
+ entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
+ }).reportability
+ ).toBe('none')
+ })
+
+ it('classifies the newest failure instead of an older persisted 401', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ pendingPath: 'relay',
+ entries: [
+ event('Relay: relay dial failed', 'relay director resolve failed (401)'),
+ { ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
+ ]
+ }).likelyCause
+ ).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
+ })
+
+ it('requires structured Relay evidence before offering submission', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ activePath: 'relay',
+ entries: [event('active relay session failed')]
+ }).reportability
+ ).toBe('none')
+ })
+
+ it('ignores failures from before the current app resume boundary', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'connecting',
+ activePath: 'lan',
+ entries: [
+ {
+ ...event('Relay health check failed'),
+ code: 'liveness-timeout',
+ path: 'relay'
+ },
+ {
+ ...event('App returned to foreground'),
+ id: 'resume',
+ ts: 2,
+ code: 'app-resumed'
+ },
+ { ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
+ ]
+ }).reportability
+ ).toBe('none')
+ })
+
+ it('uses a structured direct liveness path ahead of the current active path', () => {
+ expect(
+ diagnoseConnection({
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ activePath: 'relay',
+ entries: [
+ {
+ ...event('Connection health check failed'),
+ code: 'liveness-timeout',
+ path: 'lan'
+ }
+ ]
+ })
+ ).toEqual({
+ likelyCause: 'The connected host stopped answering authenticated health checks.',
+ nextStep: 'Orca closed the stale session and started recovery.',
+ reportability: 'none'
+ })
+ })
+
+ it('keys reportability to the current structured incident', () => {
+ const args = {
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting' as const,
+ activePath: 'relay' as const,
+ entries: [
+ { ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
+ {
+ ...event('Relay health check failed'),
+ id: 'current-relay-failure',
+ ts: 2,
+ code: 'liveness-timeout' as const,
+ path: 'relay' as const
+ }
+ ]
+ }
+
+ expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
+ expect(
+ getReportableConnectionIncidentId({
+ ...args,
+ entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
+ })
+ ).toBeNull()
+ })
+})
diff --git a/mobile/src/diagnostics/connection-diagnostics-analysis.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.ts
new file mode 100644
index 00000000000..117f52e236b
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-analysis.ts
@@ -0,0 +1,168 @@
+import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
+import type {
+ ConnectionLogEntry,
+ ConnectionState,
+ MobileConnectionDiagnosticPath
+} from '../transport/types'
+
+export type ConnectionDiagnosis = {
+ likelyCause: string
+ nextStep: string
+ reportability: 'none' | 'orca-relay'
+}
+
+type DiagnoseConnectionArgs = {
+ endpoint: string
+ state: ConnectionState
+ activePath?: MobileConnectionDiagnosticPath
+ pendingPath?: MobileConnectionDiagnosticPath | null
+ entries: readonly ConnectionLogEntry[]
+}
+
+export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
+ if (args.state === 'connected') {
+ return {
+ likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
+ nextStep: 'No action needed.',
+ reportability: 'none'
+ }
+ }
+ const failure = findCurrentDiagnosticFailure(args.entries)
+ const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
+
+ if (/relay director resolve failed \(401\)/i.test(evidence)) {
+ return {
+ likelyCause: 'Relay rejected the saved resume credential.',
+ nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
+ reportability: 'none'
+ }
+ }
+
+ if (/relay director resolve failed \(503\)/i.test(evidence)) {
+ const retryMs = parseRetryDelayMs(evidence)
+ return {
+ likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
+ nextStep: 'Keep Orca open; recovery should retry automatically.',
+ reportability: 'none'
+ }
+ }
+
+ if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
+ const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
+ const structuredDirectLiveness =
+ failure?.code === 'liveness-timeout' &&
+ (failure.path === 'lan' || failure.path === 'tailscale')
+ const path =
+ relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
+ ? 'Relay'
+ : 'The connected host'
+ return {
+ likelyCause: `${path} stopped answering authenticated health checks.`,
+ nextStep: 'Orca closed the stale session and started recovery.',
+ reportability: relayLiveness ? 'orca-relay' : 'none'
+ }
+ }
+
+ if (/relay-session-failed|active relay session failed/i.test(evidence)) {
+ return {
+ likelyCause: 'The active Relay session closed unexpectedly.',
+ nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
+ reportability:
+ failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
+ }
+ }
+
+ if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
+ return {
+ likelyCause: 'The desktop rejected this device during authentication.',
+ nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
+ reportability: 'none'
+ }
+ }
+
+ if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
+ return {
+ likelyCause: isTailscaleEndpoint(args.endpoint)
+ ? 'The saved Tailscale endpoint did not answer before the connection timeout.'
+ : 'The saved direct endpoint did not answer before the connection timeout.',
+ nextStep:
+ args.pendingPath === 'relay'
+ ? 'Relay recovery is in progress; keep Orca open while it retries.'
+ : 'Check the local/VPN network and confirm the desktop is awake.',
+ reportability: 'none'
+ }
+ }
+
+ if (/handshake-timeout|handshake timeout/i.test(evidence)) {
+ return {
+ likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
+ nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
+ reportability: 'none'
+ }
+ }
+
+ if (args.pendingPath === 'relay') {
+ return {
+ likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
+ nextStep: 'Keep this page open while the next recovery event is recorded.',
+ reportability: 'none'
+ }
+ }
+
+ return {
+ likelyCause: 'No single failure cause can be determined from the recorded events.',
+ nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
+ reportability: 'none'
+ }
+}
+
+export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
+ if (diagnoseConnection(args).reportability !== 'orca-relay') {
+ return null
+ }
+ return findCurrentDiagnosticFailure(args.entries)?.id ?? null
+}
+
+function findCurrentDiagnosticFailure(
+ entries: readonly ConnectionLogEntry[]
+): ConnectionLogEntry | undefined {
+ const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
+ return entries
+ .slice(boundaryIndex + 1)
+ .toReversed()
+ .find(isDiagnosticFailure)
+}
+
+function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
+ return (
+ entry.code === 'client-session-started' ||
+ entry.code === 'app-resumed' ||
+ entry.code === 'network-changed' ||
+ entry.code === 'relay-connected' ||
+ entry.code === 'direct-connected' ||
+ entry.message === 'Authenticated'
+ )
+}
+
+function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
+ const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
+ return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
+ evidence
+ )
+}
+
+function parseRetryDelayMs(evidence: string): number | null {
+ const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
+ return match ? Number(match[1]) : null
+}
+
+function formatDelay(ms: number): string {
+ return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
+}
+
+function formatPath(path: MobileConnectionDiagnosticPath): string {
+ if (path === 'relay') {
+ return 'Relay'
+ }
+ return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
+}
diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts
index d2326ffa022..35a1425165b 100644
--- a/mobile/src/diagnostics/connection-diagnostics-report.test.ts
+++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
+ desktopAppVersion: '1.4.191',
entries: [
{
id: 'log-1',
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
+ expect(report).toContain('Host Orca version: 1.4.191')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
+ expect(report).toContain('Host Orca version: unknown')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
- expect(report).toContain('No connection events recorded this session.')
+ expect(report).toContain('No connection events recorded.')
+ })
+
+ it('explains the most likely cause and redacts credentials before copying', () => {
+ const report = buildConnectionDiagnosticsReport({
+ hostName: 'Host 3',
+ endpoint: 'ws://100.88.90.25:6768',
+ state: 'reconnecting',
+ reconnectAttempts: 4,
+ lastConnectedAt: null,
+ platform: 'android 36',
+ appVersion: '0.0.46',
+ activePath: 'tailscale',
+ pendingPath: 'relay',
+ entries: [
+ {
+ id: 'relay-failure',
+ ts: NOW - 5_000,
+ level: 'error',
+ message: 'Relay: relay dial failed',
+ detail:
+ 'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
+ }
+ ],
+ nowMs: NOW
+ })
+
+ expect(report).toContain(
+ 'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
+ )
+ expect(report).toContain('Path: active=tailscale; recovery=relay')
+ expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
+ expect(report).toContain('resumeToken=[redacted]')
+ expect(report).not.toContain('secret-resume-token')
+ })
+
+ it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
+ const report = buildConnectionDiagnosticsReport({
+ hostName: 'Host 4',
+ endpoint: 'not-a-url?token=endpoint-secret',
+ state: 'reconnecting',
+ reconnectAttempts: 1,
+ lastConnectedAt: null,
+ platform: 'android 36',
+ appVersion: '0.0.47',
+ entries: [
+ {
+ id: 'json-secret',
+ ts: NOW,
+ level: 'error',
+ message: 'Relay failed',
+ detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
+ }
+ ],
+ nowMs: NOW
+ })
+
+ expect(report).toContain('Endpoint: invalid endpoint')
+ expect(report).not.toContain('endpoint-secret')
+ expect(report).not.toContain('json-secret')
+ expect(report).not.toContain('bearer-secret')
+ })
+
+ it('bounds a single event line before submission while preserving its identity', () => {
+ const report = buildConnectionDiagnosticsReport({
+ hostName: 'Host 5',
+ endpoint: 'ws://192.168.1.2:6768',
+ state: 'reconnecting',
+ reconnectAttempts: 1,
+ lastConnectedAt: null,
+ platform: 'android 36',
+ appVersion: '0.0.47',
+ entries: [
+ {
+ id: 'oversized',
+ ts: NOW,
+ level: 'error',
+ message: `newest oversized ${'😀'.repeat(2_000)}`
+ }
+ ],
+ nowMs: NOW
+ })
+
+ expect(report).toContain('newest oversized')
+ expect(report).toContain('[truncated]')
+ expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
+ 2048
+ )
})
})
diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts
index c762c69799e..0507b44349a 100644
--- a/mobile/src/diagnostics/connection-diagnostics-report.ts
+++ b/mobile/src/diagnostics/connection-diagnostics-report.ts
@@ -1,6 +1,16 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
-import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
+import type {
+ ConnectionLogEntry,
+ ConnectionState,
+ MobileConnectionDiagnosticPath
+} from '../transport/types'
+import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
+import { diagnoseConnection } from './connection-diagnostics-analysis'
+import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
+
+const MAX_EVENT_LINE_BYTES = 2 * 1024
+const EVENT_TRUNCATION_MARKER = ' … [truncated]'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
lastConnectedAt: number | null
platform: string
appVersion: string
+ desktopAppVersion?: string | null
entries: readonly ConnectionLogEntry[]
+ activePath?: MobileConnectionDiagnosticPath
+ pendingPath?: MobileConnectionDiagnosticPath | null
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
+ const entries = args.entries.map(redactConnectionLogEntry)
+ const diagnosis = diagnoseConnection({
+ endpoint: args.endpoint,
+ state: args.state,
+ activePath: args.activePath,
+ pendingPath: args.pendingPath,
+ entries
+ })
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
- lines.push(`Host: ${args.hostName}`)
+ const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
+ lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
+ lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
+ if (args.activePath) {
+ lines.push(
+ `Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
+ )
+ }
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
- if (args.entries.length === 0) {
- lines.push('No connection events recorded this session.')
+ lines.push(`Likely cause: ${diagnosis.likelyCause}`)
+ lines.push(`Next step: ${diagnosis.nextStep}`)
+ lines.push('')
+ if (entries.length === 0) {
+ lines.push('No connection events recorded.')
} else {
- lines.push(`Connection log (${args.entries.length} events, oldest first):`)
- for (const entry of args.entries) {
+ lines.push(`Recent connection history (${entries.length} events, oldest first):`)
+ for (const entry of entries) {
const detail = entry.detail ? ` — ${entry.detail}` : ''
- lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
+ const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
+ lines.push(
+ truncateUtf8WithMarker(
+ `${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
+ MAX_EVENT_LINE_BYTES,
+ EVENT_TRUNCATION_MARKER
+ )
+ )
}
}
return lines.join('\n')
}
+function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
+ if (new TextEncoder().encode(value).byteLength <= maxBytes) {
+ return value
+ }
+ const markerBytes = new TextEncoder().encode(marker).byteLength
+ const characters: string[] = []
+ let bytes = 0
+ for (const character of value) {
+ const characterBytes = new TextEncoder().encode(character).byteLength
+ if (bytes + characterBytes + markerBytes > maxBytes) {
+ break
+ }
+ characters.push(character)
+ bytes += characterBytes
+ }
+ return `${characters.join('')}${marker}`
+}
+
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts
new file mode 100644
index 00000000000..06ec5dabf81
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ getDiagnosticsSubmissionState,
+ readConnectionDiagnosticsSnapshot,
+ readHydratedConnectionLog,
+ resolveDiagnosticsHostId,
+ selectDiagnosticsHostId,
+ updateDiagnosticsSubmissionState
+} from './connection-diagnostics-screen-data'
+import type { ConnectionLogEntry, HostProfile } from '../transport/types'
+
+const host = (id: string): HostProfile => ({
+ id,
+ name: id,
+ endpoint: 'ws://192.168.1.2:6768',
+ deviceToken: 'token',
+ publicKeyB64: 'key',
+ lastConnected: 0
+})
+
+describe('connection diagnostics screen data', () => {
+ it('prefers a valid route host when navigation changes', () => {
+ expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
+ expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
+ })
+
+ it('applies route changes synchronously without discarding a chip choice for the same route', () => {
+ const hosts = [host('a'), host('b')]
+ expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
+ expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
+ expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
+ })
+
+ it('does not revive a stale manual choice when routing away and back', () => {
+ const hosts = [host('a'), host('b'), host('c')]
+ const firstRouteA = {}
+ const routeC = {}
+ const secondRouteA = {}
+ const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
+
+ expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
+ expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
+ })
+
+ it('keeps async submission completion scoped to its host incident', () => {
+ let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
+ states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
+ states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
+
+ expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
+ expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
+ })
+
+ it('waits for hydration and then reads the refreshed log', async () => {
+ const entries: ConnectionLogEntry[] = []
+ const store = {
+ hydrate: vi.fn(async () => {
+ entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
+ }),
+ get: vi.fn(() => entries)
+ }
+
+ await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
+ expect(store.get).toHaveBeenCalledAfter(store.hydrate)
+ })
+
+ it('retries one transient hydration failure before reading', async () => {
+ const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
+ const store = {
+ hydrate: vi
+ .fn()
+ .mockRejectedValueOnce(new Error('storage unavailable'))
+ .mockResolvedValueOnce(undefined),
+ get: vi.fn(() => entries)
+ }
+
+ await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
+ expect(store.hydrate).toHaveBeenCalledTimes(2)
+ })
+
+ it('reads connection metadata after hydration completes', async () => {
+ let state: 'connecting' | 'reconnecting' = 'connecting'
+ const store = {
+ hydrate: vi.fn(async () => {
+ state = 'reconnecting'
+ }),
+ get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
+ }
+ const context = {
+ getState: vi.fn(() => state),
+ getReconnectAttempt: vi.fn(() => 2),
+ getLastConnectedAt: vi.fn(() => 1),
+ getActivePath: vi.fn(() => 'relay' as const),
+ getPendingPath: vi.fn(() => null)
+ }
+
+ await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
+ state: 'reconnecting',
+ reconnectAttempts: 2,
+ lastConnectedAt: 1,
+ activePath: 'relay',
+ pendingPath: null,
+ entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
+ })
+ expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
+ })
+})
diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts
new file mode 100644
index 00000000000..c8a2ed589c8
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts
@@ -0,0 +1,94 @@
+import type { ConnectionLogStore } from '../transport/connection-log-buffer'
+import type { ConnectionLogEntry, HostProfile } from '../transport/types'
+import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
+
+export type DiagnosticsHostSelection = {
+ hostId: string
+ requestedHostId: string | undefined
+ routeKey?: object
+}
+
+export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
+export type DiagnosticsSubmissionStates = Readonly>
+
+export function selectDiagnosticsHostId(
+ hosts: readonly HostProfile[],
+ requestedHostId: string | undefined,
+ previousHostId: string | null
+): string | null {
+ if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
+ return requestedHostId
+ }
+ if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
+ return previousHostId
+ }
+ return hosts[0]?.id ?? null
+}
+
+export function resolveDiagnosticsHostId(
+ hosts: readonly HostProfile[],
+ requestedHostId: string | undefined,
+ manualSelection: DiagnosticsHostSelection | null,
+ routeKey?: object
+): string | null {
+ const selected = manualSelection
+ if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
+ const manualHostId = selected.hostId
+ if (hosts.some((host) => host.id === manualHostId)) {
+ return manualHostId
+ }
+ }
+ return selectDiagnosticsHostId(hosts, requestedHostId, null)
+}
+
+export function getDiagnosticsSubmissionState(
+ states: DiagnosticsSubmissionStates,
+ key: string | null
+): DiagnosticsSubmissionState | 'idle' {
+ return key ? (states[key] ?? 'idle') : 'idle'
+}
+
+export function updateDiagnosticsSubmissionState(
+ states: DiagnosticsSubmissionStates,
+ key: string,
+ state: DiagnosticsSubmissionState | null
+): DiagnosticsSubmissionStates {
+ const next = { ...states }
+ if (state) {
+ next[key] = state
+ } else {
+ delete next[key]
+ }
+ return next
+}
+
+export async function readHydratedConnectionLog(
+ store: Pick,
+ hostId: string
+): Promise {
+ try {
+ await store.hydrate(hostId)
+ } catch {
+ await store.hydrate(hostId).catch(() => {})
+ }
+ return store.get(hostId)
+}
+
+export async function readConnectionDiagnosticsSnapshot(
+ context: Pick<
+ RpcClientContextValue,
+ 'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
+ >,
+ store: Pick,
+ hostId: string
+) {
+ const entries = await readHydratedConnectionLog(store, hostId)
+ return {
+ state: context.getState(hostId),
+ reconnectAttempts: context.getReconnectAttempt(hostId),
+ lastConnectedAt: context.getLastConnectedAt(hostId),
+ activePath: context.getActivePath(hostId),
+ pendingPath: context.getPendingPath(hostId),
+ entries
+ }
+}
diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.test.ts b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts
new file mode 100644
index 00000000000..f2977b6055c
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ boundConnectionDiagnosticsReport,
+ submitConnectionDiagnostics
+} from './connection-diagnostics-submission'
+
+describe('submitConnectionDiagnostics', () => {
+ it('sends a bounded report through the diagnostics lane', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
+ const result = await submitConnectionDiagnostics(
+ { report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
+ fetchImpl
+ )
+
+ expect(result).toEqual({ ok: true })
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://www.onorca.dev/v1/feedback',
+ expect.objectContaining({ method: 'POST' })
+ )
+ const request = fetchImpl.mock.calls[0]?.[1]
+ const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
+ expect(body.submissionType).toBe('connection_diagnostics')
+ expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
+ })
+
+ it('returns a safe failure for a rejected response', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 503 }))
+ await expect(
+ submitConnectionDiagnostics(
+ { report: 'report', appVersion: '0.0.47', platform: 'android' },
+ fetchImpl
+ )
+ ).resolves.toEqual({ ok: false, error: 'status 503' })
+ })
+
+ it('preserves the newest complete events when bounding a UTF-8 report', () => {
+ const report = [
+ 'Orca Mobile connection diagnostics',
+ 'State: reconnecting',
+ '',
+ 'Recent connection history (3 events, oldest first):',
+ `oldest ${'😀'.repeat(20)}`,
+ `middle ${'😀'.repeat(20)}`,
+ `newest ${'😀'.repeat(20)}`
+ ].join('\n')
+
+ const bounded = boundConnectionDiagnosticsReport(report, 180)
+
+ expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
+ expect(bounded).toContain('newest')
+ expect(bounded).not.toContain('oldest')
+ expect(bounded).toMatch(/older omitted/)
+ })
+
+ it('retains a bounded form of the newest event when that event exceeds the budget', () => {
+ const report = [
+ 'Orca Mobile connection diagnostics',
+ 'State: reconnecting',
+ '',
+ 'Recent connection history (2 events, oldest first):',
+ 'old event',
+ `newest oversized ${'😀'.repeat(1_000)}`
+ ].join('\n')
+
+ const bounded = boundConnectionDiagnosticsReport(report, 220)
+
+ expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
+ expect(bounded).toContain('newest oversized')
+ expect(bounded).toContain('[truncated]')
+ })
+})
diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.ts b/mobile/src/diagnostics/connection-diagnostics-submission.ts
new file mode 100644
index 00000000000..67e220f86c7
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-submission.ts
@@ -0,0 +1,125 @@
+const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
+const SUBMISSION_TIMEOUT_MS = 10_000
+const MAX_SUBMISSION_BYTES = 64 * 1024
+
+export type ConnectionDiagnosticsSubmission = {
+ report: string
+ appVersion: string
+ platform: string
+}
+
+export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
+
+/** Sends only the already-redacted, bounded report after an explicit user tap. */
+export async function submitConnectionDiagnostics(
+ submission: ConnectionDiagnosticsSubmission,
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const report = boundConnectionDiagnosticsReport(submission.report)
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
+ try {
+ const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ feedback: report,
+ submissionType: 'connection_diagnostics',
+ githubLogin: null,
+ githubEmail: null,
+ appVersion: submission.appVersion,
+ platform: `mobile-${submission.platform}`,
+ osRelease: 'unknown',
+ arch: 'unknown'
+ }),
+ signal: controller.signal
+ })
+ return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
+ } catch (error) {
+ return {
+ ok: false,
+ error: controller.signal.aborted
+ ? 'request timed out'
+ : error instanceof Error
+ ? error.message
+ : 'request failed'
+ }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+export function boundConnectionDiagnosticsReport(
+ report: string,
+ maxBytes: number = MAX_SUBMISSION_BYTES
+): string {
+ if (utf8Bytes(report) <= maxBytes) {
+ return report
+ }
+ const lines = report.split('\n')
+ const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
+ if (historyIndex === -1) {
+ return truncateUtf8(report, maxBytes)
+ }
+ const header = lines.slice(0, historyIndex)
+ const events = lines.slice(historyIndex + 1)
+ let kept: string[] = []
+ for (let index = events.length - 1; index >= 0; index--) {
+ const candidateEvents = [events[index]!, ...kept]
+ const candidate = formatBoundedReport(header, candidateEvents, events.length)
+ if (utf8Bytes(candidate) > maxBytes) {
+ break
+ }
+ kept = candidateEvents
+ }
+ if (kept.length === 0 && events.length > 0) {
+ return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
+ }
+ return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
+}
+
+function formatReportWithTruncatedNewestEvent(
+ header: string[],
+ events: string[],
+ maxBytes: number
+): string {
+ const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
+ const prefix = [...header, history].join('\n') + '\n'
+ const availableBytes = maxBytes - utf8Bytes(prefix)
+ if (availableBytes <= 0) {
+ return truncateUtf8(prefix, maxBytes)
+ }
+ const marker = ' … [truncated]'
+ const markerBytes = utf8Bytes(marker)
+ if (availableBytes <= markerBytes) {
+ return truncateUtf8(prefix, maxBytes)
+ }
+ return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
+}
+
+function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
+ const omitted = totalEvents - events.length
+ return [
+ ...header,
+ `Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
+ ...events
+ ].join('\n')
+}
+
+function truncateUtf8(value: string, maxBytes: number): string {
+ const characters: string[] = []
+ let bytes = 0
+ for (const character of value) {
+ const characterBytes = utf8Bytes(character)
+ if (bytes + characterBytes > maxBytes) {
+ break
+ }
+ characters.push(character)
+ bytes += characterBytes
+ }
+ return characters.join('')
+}
+
+function utf8Bytes(value: string): number {
+ return new TextEncoder().encode(value).byteLength
+}
diff --git a/mobile/src/diagnostics/connection-log-redaction.ts b/mobile/src/diagnostics/connection-log-redaction.ts
new file mode 100644
index 00000000000..0461385172a
--- /dev/null
+++ b/mobile/src/diagnostics/connection-log-redaction.ts
@@ -0,0 +1,51 @@
+import type { ConnectionLogEntry } from '../transport/types'
+
+const SECRET_JSON_ASSIGNMENT =
+ /(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
+const SECRET_QUOTED_VALUE_ASSIGNMENT =
+ /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
+const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
+ /(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
+const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
+ /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
+const SECRET_ASSIGNMENT =
+ /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
+const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^\s]+/gi
+const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
+
+export function redactConnectionLogText(value: string): string {
+ return value
+ .replace(
+ SECRET_JSON_ASSIGNMENT,
+ (_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
+ `${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
+ )
+ .replace(
+ SECRET_QUOTED_VALUE_ASSIGNMENT,
+ (_match, label: string, separator: string, valueQuote: string) =>
+ `${label}${separator}${valueQuote}[redacted]${valueQuote}`
+ )
+ .replace(
+ SECRET_UNTERMINATED_JSON_ASSIGNMENT,
+ (_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
+ `${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
+ )
+ .replace(
+ SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
+ (_match, label: string, separator: string, valueQuote: string) =>
+ `${label}${separator}${valueQuote}[redacted]`
+ )
+ .replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
+ return `${label}${separator}[redacted]`
+ })
+ .replace(SECRET_QUERY, '$1[redacted]')
+ .replace(URL_USERINFO, '$1[redacted]@')
+}
+
+export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
+ return {
+ ...entry,
+ message: redactConnectionLogText(entry.message),
+ ...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
+ }
+}
diff --git a/mobile/src/diagnostics/host-reachability.test.ts b/mobile/src/diagnostics/host-reachability.test.ts
index d20bc8c28cf..3d2bd5b80e5 100644
--- a/mobile/src/diagnostics/host-reachability.test.ts
+++ b/mobile/src/diagnostics/host-reachability.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { testHostReachability, unreachableHostDetail } from './host-reachability'
+import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
it('points at Tailscale for tailnet CGNAT endpoints', () => {
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
})
+
+ it('does not echo malformed endpoints that may contain credentials', () => {
+ expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
+ })
})
describe('testHostReachability', () => {
diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts
index 495d65a9764..f5333bb5405 100644
--- a/mobile/src/diagnostics/host-reachability.ts
+++ b/mobile/src/diagnostics/host-reachability.ts
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
const url = new URL(endpoint)
return url.host
} catch {
- return endpoint
+ return 'invalid endpoint'
}
}
diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx
index 44c65bf018a..83cf3de4b8a 100644
--- a/mobile/src/home/MobileHomeScreen.tsx
+++ b/mobile/src/home/MobileHomeScreen.tsx
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: disconnectHostClient,
+ onDiagnostics: (hostId) =>
+ data.router.push({ pathname: '/connection-log', params: { hostId } }),
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
diff --git a/mobile/src/host-list-action-sheet-actions.test.ts b/mobile/src/host-list-action-sheet-actions.test.ts
index 2fb90334082..3672bd81f96 100644
--- a/mobile/src/host-list-action-sheet-actions.test.ts
+++ b/mobile/src/host-list-action-sheet-actions.test.ts
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
import type { ConnectionState, HostProfile } from './transport/types'
vi.mock('lucide-react-native', () => ({
+ Activity: vi.fn(),
Edit3: vi.fn(),
PowerOff: vi.fn(),
RefreshCw: vi.fn()
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
+ onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
}
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
}
describe('getHostListActionSheetActions', () => {
- // Why: both open a second drawer. Presenting one while this sheet's native
+ // Why: these navigate or open a second drawer. Presenting while this sheet's native
// Modal is still up freezes the whole screen on iOS — issue #8791.
- it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
- const { actions } = build()
- expect(actions.find((action) => action.label === label)).toMatchObject({
- closeBeforePress: true
- })
- })
+ it.each(['Network diagnostics', 'Edit host', 'Remove'])(
+ 'defers %s until the action sheet has closed',
+ (label) => {
+ const { actions } = build()
+ expect(actions.find((action) => action.label === label)).toMatchObject({
+ closeBeforePress: true
+ })
+ }
+ )
it('leaves the in-place actions undeferred so they fire on tap', () => {
const { actions, spies } = build()
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
})
+ it('routes Network diagnostics with the selected host', () => {
+ const { actions, spies } = build()
+ actions.find((action) => action.label === 'Network diagnostics')?.onPress()
+ expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
+ })
+
it('offers Disconnect only while the socket is live', () => {
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
'Reconnect',
'Disconnect',
+ 'Network diagnostics',
'Edit host',
'Remove'
])
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
'Connect',
+ 'Network diagnostics',
'Edit host',
'Remove'
])
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
+ onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
})
diff --git a/mobile/src/host-list-action-sheet-actions.ts b/mobile/src/host-list-action-sheet-actions.ts
index b045db7eea4..22b1472e7c3 100644
--- a/mobile/src/host-list-action-sheet-actions.ts
+++ b/mobile/src/host-list-action-sheet-actions.ts
@@ -1,9 +1,9 @@
-import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
+import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import type { ActionSheetAction } from './components/ActionSheetModal'
import type { ConnectionState, HostProfile } from './transport/types'
-/** Builds the home-screen host long-press menu. Edit and Remove open a second
- * drawer, so both must defer until this sheet's native Modal has unmounted —
+/** Builds the home-screen host long-press menu. Navigation and second drawers
+ * defer until this sheet's native Modal has unmounted —
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
export function getHostListActionSheetActions(args: {
host: HostProfile | null
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
onDismiss: () => void
onReconnect: (hostId: string) => void
onDisconnect: (hostId: string) => void
+ onDiagnostics: (hostId: string) => void
onEdit: (hostId: string) => void
onRemove: (host: HostProfile) => void
}): ActionSheetAction[] {
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
}
]
: []),
+ {
+ label: 'Network diagnostics',
+ icon: Activity,
+ closeBeforePress: true,
+ onPress: () => {
+ args.onDiagnostics(host.id)
+ }
+ },
{
label: 'Edit host',
icon: Edit3,
diff --git a/mobile/src/host-screen/host-screen-primary-styles.ts b/mobile/src/host-screen/host-screen-primary-styles.ts
new file mode 100644
index 00000000000..c499ad2afe6
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-primary-styles.ts
@@ -0,0 +1,175 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const hostScreenPrimaryStyles = 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
+ }
+})
diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts
new file mode 100644
index 00000000000..af14aa2b97a
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-secondary-styles.ts
@@ -0,0 +1,139 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+export const hostScreenSecondaryStyles = StyleSheet.create({
+ 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/src/host-screen/host-screen-styles.ts b/mobile/src/host-screen/host-screen-styles.ts
new file mode 100644
index 00000000000..f4fdb7bed5d
--- /dev/null
+++ b/mobile/src/host-screen/host-screen-styles.ts
@@ -0,0 +1,7 @@
+import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
+import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
+
+export const hostScreenStyles = {
+ ...hostScreenPrimaryStyles,
+ ...hostScreenSecondaryStyles
+}
diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts
new file mode 100644
index 00000000000..eab103eac15
--- /dev/null
+++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ createTerminalAccessoryRepeatController,
+ createTerminalAccessoryRepeatSender,
+ TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
+ TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
+} from './terminal-accessory-repeat'
+
+function deferred() {
+ let resolve: () => void = () => {}
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+describe('terminal accessory repeat', () => {
+ afterEach(() => vi.useRealTimers())
+
+ it('keeps one send in flight and preserves repeat order under host latency', async () => {
+ vi.useFakeTimers()
+ const first = deferred()
+ const second = deferred()
+ const sent: string[] = []
+ const send = vi.fn((input: string) => {
+ sent.push(input)
+ return (sent.length === 1 ? first.promise : second.promise).then(() => true)
+ })
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', send)
+ expect(sent).toEqual(['down'])
+
+ await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
+ expect(sent).toEqual(['down'])
+
+ first.resolve()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sent).toEqual(['down', 'down'])
+
+ await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
+ expect(sent).toEqual(['down', 'down'])
+
+ second.resolve()
+ await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
+ expect(sent).toEqual(['down', 'down', 'down'])
+ })
+
+ it('does not schedule another repeat after release while a send is pending', async () => {
+ vi.useFakeTimers()
+ const pending = deferred()
+ const send = vi.fn(() => pending.promise.then(() => true))
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', send)
+ repeat.stop()
+ pending.resolve()
+ await vi.runAllTimersAsync()
+
+ expect(send).toHaveBeenCalledTimes(1)
+ })
+
+ it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
+ vi.useFakeTimers()
+ const pending = Array.from({ length: 5 }, () => deferred())
+ const sent: string[] = []
+ const send = vi.fn((input: string) => {
+ sent.push(input)
+ return pending[sent.length - 1]!.promise.then(() => true)
+ })
+ const repeat = createTerminalAccessoryRepeatController()
+
+ for (let index = 0; index < pending.length; index += 1) {
+ repeat.start(`tap-${index}`, send)
+ repeat.stop()
+ }
+
+ expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
+
+ pending.forEach(({ resolve }) => resolve())
+ await vi.runAllTimersAsync()
+ expect(send).toHaveBeenCalledTimes(5)
+ })
+
+ it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
+ vi.useFakeTimers()
+ const first = deferred()
+ const sent: string[] = []
+ const send = vi.fn((input: string) => {
+ sent.push(input)
+ return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
+ })
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', send)
+ repeat.stop()
+ repeat.start('up', send)
+ repeat.cancel()
+
+ first.resolve()
+ await vi.runAllTimersAsync()
+
+ expect(sent).toEqual(['down', 'up'])
+ expect(send).toHaveBeenCalledTimes(2)
+ })
+
+ it('stops repeating when the transport rejects a send', async () => {
+ vi.useFakeTimers()
+ const send = vi.fn(() => Promise.reject(new Error('disconnected')))
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', send)
+ await vi.runAllTimersAsync()
+
+ expect(send).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops repeating when the send is dropped before reaching the transport', async () => {
+ vi.useFakeTimers()
+ const send = vi.fn(async () => false)
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', send)
+ await vi.runAllTimersAsync()
+
+ expect(send).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops sending when the terminal active at press time is no longer active', async () => {
+ let activeTerminal = 'terminal-a'
+ const sendToTerminal = vi.fn(async () => true)
+ const send = createTerminalAccessoryRepeatSender(
+ activeTerminal,
+ (targetHandle) => activeTerminal === targetHandle,
+ sendToTerminal
+ )
+
+ await expect(send('down')).resolves.toBe(true)
+ activeTerminal = 'terminal-b'
+ await expect(send('down')).resolves.toBe(false)
+
+ expect(sendToTerminal).toHaveBeenCalledTimes(1)
+ expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
+ })
+
+ it('provides a live target guard for async send preflight', async () => {
+ let targetCurrent = true
+ const preflight = deferred()
+ const send = createTerminalAccessoryRepeatSender(
+ 'terminal-a',
+ () => targetCurrent,
+ async (_input, _targetHandle, isDeliveryTargetCurrent) => {
+ await preflight.promise
+ return isDeliveryTargetCurrent()
+ }
+ )
+
+ const result = send('down')
+ targetCurrent = false
+ preflight.resolve()
+
+ await expect(result).resolves.toBe(false)
+ })
+
+ it('stops held repeats when the press-time connection is no longer current', async () => {
+ vi.useFakeTimers()
+ let connectionGeneration = 1
+ const first = deferred()
+ const sent: string[] = []
+ const sendToTerminal = vi.fn((input: string) => {
+ sent.push(input)
+ return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
+ })
+ const createSender = () => {
+ const pressedConnectionGeneration = connectionGeneration
+ return createTerminalAccessoryRepeatSender(
+ 'terminal-a',
+ () => connectionGeneration === pressedConnectionGeneration,
+ sendToTerminal
+ )
+ }
+ const repeat = createTerminalAccessoryRepeatController()
+
+ repeat.start('down', createSender())
+
+ connectionGeneration = 2
+ first.resolve()
+ await vi.runAllTimersAsync()
+
+ expect(sent).toEqual(['down'])
+ })
+})
diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts
new file mode 100644
index 00000000000..2b354b42d59
--- /dev/null
+++ b/mobile/src/terminal/terminal-accessory-repeat.ts
@@ -0,0 +1,76 @@
+export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
+export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
+
+type TerminalAccessoryRepeatSender = (input: TInput) => Promise
+type TerminalAccessoryRepeatSendToTerminal = (
+ input: TInput,
+ targetHandle: string,
+ isDeliveryTargetCurrent: () => boolean
+) => Promise
+
+export function createTerminalAccessoryRepeatSender(
+ targetHandle: string | null,
+ isDeliveryTargetCurrent: (targetHandle: string) => boolean,
+ sendToTerminal: TerminalAccessoryRepeatSendToTerminal
+): TerminalAccessoryRepeatSender {
+ return (input) => {
+ if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
+ return Promise.resolve(false)
+ }
+ return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
+ }
+}
+
+export function createTerminalAccessoryRepeatController() {
+ let generation = 0
+ let timer: ReturnType | null = null
+
+ const stop = () => {
+ generation += 1
+ if (timer !== null) {
+ clearTimeout(timer)
+ timer = null
+ }
+ }
+
+ const cancel = stop
+
+ const start = (input: TInput, send: TerminalAccessoryRepeatSender) => {
+ stop()
+ const activeGeneration = generation
+ const pressedAt = Date.now()
+
+ const schedule = (delayMs: number) => {
+ timer = setTimeout(() => {
+ timer = null
+ if (generation !== activeGeneration) {
+ return
+ }
+ void send(input).then(
+ (sent) => {
+ if (sent && generation === activeGeneration) {
+ schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
+ }
+ },
+ () => undefined
+ )
+ }, delayMs)
+ }
+
+ void send(input).then(
+ (sent) => {
+ if (sent && generation === activeGeneration) {
+ schedule(
+ Math.min(
+ TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
+ Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
+ )
+ )
+ }
+ },
+ () => undefined
+ )
+ }
+
+ return { cancel, start, stop }
+}
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
index 7a1110b407b..4ecab7dd10c 100644
--- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse } from '../transport/types'
-function captureClient(result: Promise = Promise.resolve({ ok: true })) {
+function captureClient(
+ result: Promise = Promise.resolve({
+ id: 'send',
+ ok: true,
+ result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
+ _meta: { runtimeId: 'test-runtime' }
+ })
+) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick, sendRequest }
}
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
- await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
+ await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes instead of sending while disconnected', async () => {
const { client, sendRequest } = captureClient()
- await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
+ await expect(
+ sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
+ ).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
const { client, sendRequest } = captureClient()
- await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
+ await expect(
+ sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
+ ).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
- it('swallows a rejected send so accessory taps never surface transport errors', async () => {
+ it('reports a rejected send without surfacing transport errors', async () => {
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
- await expect(
- sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
- ).resolves.toBeUndefined()
+ await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
+ })
+
+ it('reports a fulfilled RPC failure as a failed send', async () => {
+ const response: RpcResponse = {
+ id: 'send',
+ ok: false,
+ error: { code: 'terminal_error', message: 'failed' },
+ _meta: { runtimeId: 'test-runtime' }
+ }
+ const { client } = captureClient(Promise.resolve(response))
+
+ await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
})
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts
index 792491f6c63..3824d750792 100644
--- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts
@@ -1,4 +1,5 @@
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
+import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
export async function sendTerminalLiveAccessoryRawBytes(
args: TerminalLiveAccessoryRawSendArgs
-): Promise {
+): Promise {
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle: args.targetHandle,
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
activeSessionTabType: args.activeSessionTabType
})
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
- return
+ return false
}
- await args.client
+ return args.client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
}),
TERMINAL_INPUT_SEND_OPTIONS
)
- .then(
- () => undefined,
- () => undefined
- )
+ .then(isTerminalSendRpcAccepted, () => false)
}
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
index 4474f043ebe..ad1d1726798 100644
--- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
@@ -70,7 +70,7 @@ function createAccessoryInputCommitHarness({
}
}
const applyLiveInputMirror = vi.fn(
- (_handle: string, _fieldText: string, _composing?: boolean) => {}
+ async (_handle: string, _fieldText: string, _composing?: boolean) => true
)
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
@@ -171,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
expect(result).toEqual({ kind: 'handled' })
})
+ it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
+ const harness = createAccessoryInputCommitHarness({
+ heldText: '한',
+ pendingHandle: 'terminal-a',
+ sendResult: false
+ })
+
+ const result = await harness.commit({ bytes: '\x1b' })
+
+ expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
+ expect(harness.sent).toEqual(['\x1b'])
+ expect(result).toEqual({ kind: 'suppress-raw' })
+ })
+
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
index ef6a2339822..80a8422d085 100644
--- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
@@ -21,7 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
- readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
+ readonly applyLiveInputMirror: (
+ handle: string,
+ fieldText: string,
+ composing?: boolean
+ ) => Promise
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise
readonly heldLiveInputTextRef: RefObject
@@ -84,15 +88,20 @@ export function useTerminalLiveAccessoryInputCommit({
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
// Preserve undefined so Android's heuristic hold still settles on its timer.
- applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
- return { kind: 'handled' }
+ const sent = await applyLiveInputMirror(
+ activeHandle,
+ editedText,
+ liveInputComposingRef.current
+ )
+ return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
- case 'commit-held-then-send':
- await sendTerminalLiveControlAfterPendingFlush(
+ case 'commit-held-then-send': {
+ const sent = await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
- return { kind: 'handled' }
+ return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
+ }
default:
decision satisfies never
return { kind: 'handled' }
diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts
index b24636114a9..6077ccb4033 100644
--- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts
+++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts
@@ -190,6 +190,20 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
+ it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
+ const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
+ changeLiveInput(handlers, 'a', false)
+ await vi.waitFor(() => expect(sent).toEqual(['a']))
+
+ const result = await handlers.handleLiveInputAccessoryBytes({
+ bytes: '\x7f',
+ localEdit: 'backspace'
+ })
+
+ expect(sent).toEqual(['a', '\x7f'])
+ expect(result).toEqual({ kind: 'suppress-raw' })
+ })
+
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts
index 25bbf1ae002..952612f0a87 100644
--- a/mobile/src/terminal/use-terminal-live-input-commit.ts
+++ b/mobile/src/terminal/use-terminal-live-input-commit.ts
@@ -133,7 +133,7 @@ export function useTerminalLiveInputCommit({
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(nativeEvent.text)
- applyLiveInputMirror(
+ void applyLiveInputMirror(
activeHandle,
normalizeTerminalTextInput(nativeEvent.text),
nativeEvent.isComposing
diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
index c01aa5af213..ff0c09c1c51 100644
--- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
+++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
@@ -30,7 +30,11 @@ type RunTerminalLiveMirrorStep = (
) => Promise
type TerminalLivePendingInputFlush = {
- readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
+ readonly applyLiveInputMirror: (
+ handle: string,
+ fieldText: string,
+ composing?: boolean
+ ) => Promise
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise
readonly heldLiveInputTextRef: RefObject
@@ -153,9 +157,8 @@ export function useTerminalLivePendingInputFlush({
}, [runMirrorStep])
const applyLiveInputMirror = useCallback(
- (handle: string, fieldText: string, composing?: boolean): void => {
- void runMirrorStep(handle, fieldText, false, composing)
- },
+ (handle: string, fieldText: string, composing?: boolean): Promise =>
+ runMirrorStep(handle, fieldText, false, composing),
[runMirrorStep]
)
diff --git a/mobile/src/transport/client-context-connection-metrics.ts b/mobile/src/transport/client-context-connection-metrics.ts
index fa085d85423..1e2ff6f7919 100644
--- a/mobile/src/transport/client-context-connection-metrics.ts
+++ b/mobile/src/transport/client-context-connection-metrics.ts
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
-import { useRpcClientContext, type RpcClientContextValue } from './client-context'
+import { useRpcClientContext } from './client-context'
+import type { RpcClientContextValue } from './rpc-client-context-contract'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function useReconnectAttempt(hostId: string | undefined): number {
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
+export function useConnectionPathStatus(hostId: string | undefined): {
+ activePath: MobileConnectionPath
+ pendingPath: MobileConnectionPath | null
+} {
+ return useHostMetric(
+ hostId,
+ (context, id) => ({
+ activePath: context.getActivePath(id),
+ pendingPath: context.getPendingPath(id)
+ }),
+ { activePath: 'lan', pendingPath: null }
+ )
+}
+
// Both inputs classifyConnection needs about a relay recovery, read from one
// subscription so a screen cannot render half of the escalation.
export function useRelayRecoveryStatus(hostId: string | undefined): {
diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx
index d525d60d6d5..f83519940c1 100644
--- a/mobile/src/transport/client-context.tsx
+++ b/mobile/src/transport/client-context.tsx
@@ -21,6 +21,7 @@ import {
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
+import { recordConnectionRevival } from './persisted-connection-log-store'
import {
createHostClientSelectors,
listHostClients,
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
type StoreEntry = HostClientStoreEntry
-export type { HostClientAcquisition } from './host-client-acquisition-registry'
-export type { RpcClientContextValue } from './rpc-client-context-contract'
const Ctx = createContext(null)
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
for (const hostId of pendingAcquisitionsRef.current.keys()) {
retrySchedulerRef.current?.expedite(hostId)
}
- for (const entry of storeRef.current.values()) {
+ for (const [hostId, entry] of storeRef.current) {
+ recordConnectionRevival(hostId, reason)
try {
entry.client.notifyForeground(reason)
} catch {
diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts
index e7ce662534b..51d8bbb044d 100644
--- a/mobile/src/transport/connection-log-buffer.test.ts
+++ b/mobile/src/transport/connection-log-buffer.test.ts
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
+
+ it('hydrates persisted history without dropping events recorded during app startup', async () => {
+ let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
+ const load = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finishLoad = resolve
+ })
+ )
+ const save = vi.fn(async () => {})
+ const store = createConnectionLogStore(3, { load, save })
+
+ store.append('host-a', entry(3))
+ finishLoad([entry(1), entry(2)])
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
+ await vi.waitFor(() => expect(save).toHaveBeenCalled())
+ expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
+ })
+
+ it('redacts credentials before retaining or persisting an event', async () => {
+ const save = vi.fn(async () => {})
+ const store = createConnectionLogStore(3, { load: async () => [], save })
+
+ store.append('host-a', {
+ ...entry(1),
+ detail: 'resumeToken=do-not-copy deviceToken:also-secret'
+ })
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
+ await vi.waitFor(() => expect(save).toHaveBeenCalled())
+ expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
+ expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
+ })
+
+ it('redacts quoted credential values when the object key is unquoted', async () => {
+ const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
+
+ store.append('host-a', {
+ ...entry(1),
+ detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
+ })
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a')[0]?.detail).toBe(
+ `resumeToken: '[redacted]' deviceToken="[redacted]"`
+ )
+ })
+
+ it('redacts the full quoted credential when its value contains an escaped quote', async () => {
+ const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
+
+ store.append('host-a', {
+ ...entry(1),
+ detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
+ })
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
+ })
+
+ it('redacts unterminated quoted credentials', async () => {
+ const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
+
+ store.append('host-a', {
+ ...entry(1),
+ message: 'token="secret',
+ detail: '{"authorization":"Bearer also-secret'
+ })
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a')[0]).toMatchObject({
+ message: 'token="[redacted]',
+ detail: '{"authorization":"[redacted]'
+ })
+ })
+
+ it('redacts URL userinfo through the last authority separator', async () => {
+ const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
+
+ store.append('host-a', {
+ ...entry(1),
+ message: 'wss://user:p@ss@example.com/x',
+ detail: 'https://user:pass@example.com/x'
+ })
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a')[0]).toMatchObject({
+ message: 'wss://[redacted]@example.com/x',
+ detail: 'https://[redacted]@example.com/x'
+ })
+ })
+
+ it('does not overwrite persisted history when hydration fails and retries later', async () => {
+ const load = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(new Error('storage unavailable'))
+ .mockResolvedValueOnce([entry(1)])
+ const save = vi.fn(async () => {})
+ const store = createConnectionLogStore(3, { load, save })
+
+ store.append('host-a', entry(2))
+ await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
+ expect(save).not.toHaveBeenCalled()
+
+ await store.hydrate('host-a')
+ expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
+ })
+
+ it('preserves legacy entries that reused the same event id', async () => {
+ const store = createConnectionLogStore(3, {
+ load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
+ save: async () => {}
+ })
+
+ await store.hydrate('host-a')
+
+ expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
+ })
+
+ it('retries one transient persistence failure without requiring another append', async () => {
+ const save = vi.fn().mockResolvedValue(undefined)
+ const store = createConnectionLogStore(3, { load: async () => [], save })
+
+ await store.hydrate('host-a')
+ await vi.waitFor(() => expect(save).toHaveBeenCalled())
+ save.mockReset()
+ save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
+ store.append('host-a', entry(1))
+
+ await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
+ expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
+ })
+
+ it('does not retry failed automatic hydration for every appended event', async () => {
+ const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
+ const store = createConnectionLogStore(3, { load, save: async () => {} })
+
+ store.append('host-a', entry(1))
+ await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
+ store.append('host-a', entry(2))
+ store.append('host-a', entry(3))
+ await Promise.resolve()
+
+ expect(load).toHaveBeenCalledTimes(1)
+ await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
+ expect(load).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts
index 1b223ad8e1f..941f7971498 100644
--- a/mobile/src/transport/connection-log-buffer.ts
+++ b/mobile/src/transport/connection-log-buffer.ts
@@ -1,4 +1,5 @@
import type { ConnectionLogEntry } from './types'
+import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
+ hydrate: (hostId: string) => Promise
subscribe: (hostId: string, listener: () => void) => () => void
}
+export type ConnectionLogPersistence = {
+ load: (hostId: string) => Promise
+ save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise
+}
+
export function createConnectionLogStore(
- maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
+ maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
+ persistence?: ConnectionLogPersistence
): ConnectionLogStore {
const entriesByHost = new Map()
const listenersByHost = new Map void>>()
+ const hydratedHosts = new Set()
+ const hydrationFailedHosts = new Set()
+ const hydrationByHost = new Map>()
+ const saveByHost = new Map>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map()
const EMPTY: readonly ConnectionLogEntry[] = []
+ const trim = (entries: ConnectionLogEntry[]): void => {
+ if (entries.length > maxEntriesPerHost) {
+ entries.splice(0, entries.length - maxEntriesPerHost)
+ }
+ }
+
+ const notify = (hostId: string): void => {
+ snapshotByHost.delete(hostId)
+ const listeners = listenersByHost.get(hostId)
+ if (listeners) {
+ for (const listener of listeners) {
+ listener()
+ }
+ }
+ }
+
+ const persist = (hostId: string): void => {
+ if (!persistence || !hydratedHosts.has(hostId)) {
+ return
+ }
+ const snapshot = [...(entriesByHost.get(hostId) ?? [])]
+ const previous = saveByHost.get(hostId) ?? Promise.resolve()
+ const pending = previous
+ .catch(() => {})
+ .then(async () => {
+ try {
+ await persistence.save(hostId, snapshot)
+ } catch {
+ await persistence.save(hostId, snapshot)
+ }
+ })
+ .catch(() => {})
+ saveByHost.set(hostId, pending)
+ }
+
+ const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise => {
+ if (!persistence || hydratedHosts.has(hostId)) {
+ return
+ }
+ const existing = hydrationByHost.get(hostId)
+ if (existing) {
+ return existing
+ }
+ if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
+ return
+ }
+ const pending = persistence
+ .load(hostId)
+ .then((stored) => {
+ const live = entriesByHost.get(hostId) ?? []
+ const seen = new Set()
+ const merged: ConnectionLogEntry[] = []
+ for (const entry of [...stored, ...live]) {
+ const redacted = redactConnectionLogEntry(entry)
+ const fingerprint = JSON.stringify(redacted)
+ if (!seen.has(fingerprint)) {
+ seen.add(fingerprint)
+ merged.push(redacted)
+ }
+ }
+ merged.sort((a, b) => a.ts - b.ts)
+ trim(merged)
+ entriesByHost.set(hostId, merged)
+ hydratedHosts.add(hostId)
+ hydrationFailedHosts.delete(hostId)
+ notify(hostId)
+ persist(hostId)
+ })
+ .catch((error: unknown) => {
+ hydrationFailedHosts.add(hostId)
+ throw error
+ })
+ .finally(() => hydrationByHost.delete(hostId))
+ hydrationByHost.set(hostId, pending)
+ return pending
+ }
+
return {
append(hostId, entry) {
let entries = entriesByHost.get(hostId)
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
entries = []
entriesByHost.set(hostId, entries)
}
- entries.push(entry)
- if (entries.length > maxEntriesPerHost) {
- entries.splice(0, entries.length - maxEntriesPerHost)
- }
- snapshotByHost.delete(hostId)
- const listeners = listenersByHost.get(hostId)
- if (listeners) {
- for (const listener of listeners) {
- listener()
- }
- }
+ entries.push(redactConnectionLogEntry(entry))
+ trim(entries)
+ notify(hostId)
+ void hydrateHost(hostId, false)
+ .then(() => persist(hostId))
+ .catch(() => {})
},
get(hostId) {
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
return snapshot
},
+ hydrate: (hostId) => hydrateHost(hostId, true),
+
subscribe(hostId, listener) {
let listeners = listenersByHost.get(hostId)
if (!listeners) {
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
}
}
}
-
-export const connectionLogStore = createConnectionLogStore()
diff --git a/mobile/src/transport/direct-connection-log.ts b/mobile/src/transport/direct-connection-log.ts
new file mode 100644
index 00000000000..ef805e643c5
--- /dev/null
+++ b/mobile/src/transport/direct-connection-log.ts
@@ -0,0 +1,46 @@
+import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
+import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
+import type {
+ ConnectionLogEntry,
+ ConnectionLogLevel,
+ ConnectionLogSink,
+ MobileConnectionDiagnosticPath
+} from './types'
+
+export class DirectConnectionLog {
+ private sequence = 0
+ private readonly path: MobileConnectionDiagnosticPath
+
+ constructor(
+ endpoint: string,
+ private readonly sink?: ConnectionLogSink
+ ) {
+ this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
+ }
+
+ emit = (
+ level: ConnectionLogLevel,
+ message: string,
+ detail?: string,
+ evidence?: Pick
+ ): void => {
+ this.sink?.({
+ id: `log-${++this.sequence}-${Date.now()}`,
+ ts: Date.now(),
+ level,
+ message,
+ detail,
+ ...evidence,
+ path: evidence?.path ?? this.path
+ })
+ }
+
+ livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
+ this.emit(
+ 'error',
+ 'Connection health check failed',
+ `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
+ { code: 'liveness-timeout' }
+ )
+ }
+}
diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts
index 4ac7e9fb361..36ed573aa5b 100644
--- a/mobile/src/transport/direct-rpc-client.ts
+++ b/mobile/src/transport/direct-rpc-client.ts
@@ -1,4 +1,5 @@
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
+import { DirectConnectionLog } from './direct-connection-log'
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import {
@@ -16,12 +17,7 @@ import {
} from './rpc-client-stream-registry'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
-import type {
- ConnectionLogLevel,
- ConnectionState,
- ForegroundNudgeReason,
- RpcResponse
-} from './types'
+import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
private readonly authenticationRetry: RpcClientAuthenticationRetry
private readonly socketClose: RpcClientSocketCloseController
private requestCounter = 0
- private logCounter = 0
+ private readonly connectionLog: DirectConnectionLog
private intentionallyClosed = false
private authenticationGeneration = 0
private livenessSession: RpcClientSocketSession | null = null
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
serverPublicKeyB64: string,
private readonly options: ConnectOptions
) {
+ this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
- emitLog: (message, detail) => this.emitLog('info', message, detail)
+ emitLog: (message, detail) =>
+ this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
})
this.connectionState = new RpcClientConnectionState({
endpoint,
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
this.socketClose.forceClose(this.livenessSession)
}
- }
+ },
+ onTimeout: this.connectionLog.livenessTimeout
})
this.socketFactory = new RpcClientSocketFactory({
endpoint,
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
getReconnectAttempt: () => this.getReconnectAttempt(),
getLastConnectedAt: () => this.getLastConnectedAt(),
isIntentionallyClosed: () => this.intentionallyClosed,
- emitLog: (level, message, detail) => this.emitLog(level, message, detail),
+ emitLog: this.connectionLog.emit,
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
onAuthenticated: (session) => this.handleAuthenticated(session),
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
- emitWarning: (message, detail) => this.emitLog('warn', message, detail),
+ emitWarning: (message, detail) =>
+ this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
this.stopLiveness()
}
},
- emitWarning: (message, detail) => this.emitLog('warn', message, detail)
+ emitWarning: (message, detail, evidence) =>
+ this.connectionLog.emit('warn', message, detail, evidence)
})
this.openConnection()
}
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
this.reconnect.authenticated()
this.authenticationRetry.accepted()
this.connectionState.publish('connected')
- this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
+ this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
+ code: 'direct-connected'
+ })
this.streams.replayAfterAuthentication()
}
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
private nextId(): string {
return `rpc-${++this.requestCounter}-${Date.now()}`
}
-
- private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
- this.options.onLog?.({
- id: `log-${++this.logCounter}-${Date.now()}`,
- ts: Date.now(),
- level,
- message,
- detail
- })
- }
}
diff --git a/mobile/src/transport/host-app-version-store.test.ts b/mobile/src/transport/host-app-version-store.test.ts
new file mode 100644
index 00000000000..a9f9f92d105
--- /dev/null
+++ b/mobile/src/transport/host-app-version-store.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const asyncStorageMock = vi.hoisted(() => ({
+ getItem: vi.fn(),
+ setItem: vi.fn()
+}))
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: asyncStorageMock
+}))
+
+import {
+ loadHostAppVersion,
+ normalizeHostAppVersion,
+ recordHostAppVersion
+} from './host-app-version-store'
+
+describe('host app version store', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ asyncStorageMock.getItem.mockResolvedValue(null)
+ asyncStorageMock.setItem.mockResolvedValue(undefined)
+ })
+
+ it('persists a bounded version once per host', async () => {
+ asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
+
+ await recordHostAppVersion('host-1', ' 1.4.191 ')
+ await recordHostAppVersion('host-1', '1.4.191')
+
+ expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
+ expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
+ 'orca:host-app-version:v1:host-1',
+ '1.4.191'
+ )
+ })
+
+ it('loads a previously observed version without requiring a live host', async () => {
+ asyncStorageMock.getItem.mockResolvedValue('1.4.188')
+
+ await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
+ })
+
+ it('rejects multiline, empty, and oversized host-provided values', async () => {
+ expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
+ expect(normalizeHostAppVersion(' ')).toBeNull()
+ expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
+
+ await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
+ expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/transport/host-app-version-store.ts b/mobile/src/transport/host-app-version-store.ts
new file mode 100644
index 00000000000..9c649cd2d0c
--- /dev/null
+++ b/mobile/src/transport/host-app-version-store.ts
@@ -0,0 +1,47 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
+const MAX_VERSION_LENGTH = 64
+
+export function normalizeHostAppVersion(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null
+ }
+ const normalized = value.trim()
+ if (
+ normalized.length === 0 ||
+ normalized.length > MAX_VERSION_LENGTH ||
+ normalized.includes('\n') ||
+ normalized.includes('\r')
+ ) {
+ return null
+ }
+ return normalized
+}
+
+export async function loadHostAppVersion(hostId: string): Promise {
+ try {
+ return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
+ } catch {
+ return null
+ }
+}
+
+export async function recordHostAppVersion(hostId: string, value: unknown): Promise {
+ const appVersion = normalizeHostAppVersion(value)
+ if (!appVersion) {
+ return
+ }
+ try {
+ const key = storageKey(hostId)
+ if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
+ await AsyncStorage.setItem(key, appVersion)
+ }
+ } catch {
+ // Best-effort diagnostic metadata must never affect host connectivity.
+ }
+}
+
+function storageKey(hostId: string): string {
+ return `${STORAGE_KEY_PREFIX}${hostId}`
+}
diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts
index 092d205bb75..6e29f55d985 100644
--- a/mobile/src/transport/host-entry-opener.ts
+++ b/mobile/src/transport/host-entry-opener.ts
@@ -1,4 +1,7 @@
-import { connectionLogStore } from './connection-log-buffer'
+import {
+ connectionLogStore,
+ recordConnectionClientSessionStart
+} from './persisted-connection-log-store'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
@@ -65,6 +68,7 @@ export async function openHostClientEntry(
id: `host-open-${ticket.generation}-${Date.now()}`,
ts: Date.now(),
level: 'error',
+ code: 'host-open-failed',
message: 'Host client open failed',
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
})
@@ -98,6 +102,7 @@ export async function openHostClientEntry(
let client: RpcClient
try {
+ recordConnectionClientSessionStart(hostId)
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
failCurrentOpen('client-construction')
diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts
index f0454243703..7820eebc0d7 100644
--- a/mobile/src/transport/host-status-gates.test.ts
+++ b/mobile/src/transport/host-status-gates.test.ts
@@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
+const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
+
+vi.mock('./host-app-version-store', () => ({
+ normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null),
+ recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args)
+}))
+
describe('useHostStatusGates', () => {
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
let resolveOldStatus: ((response: unknown) => void) | null = null
@@ -76,6 +83,7 @@ describe('useHostStatusGates', () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
+ appVersion: '1.4.191',
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
@@ -95,11 +103,13 @@ describe('useHostStatusGates', () => {
await Promise.resolve()
})
expect(gates).toMatchObject({
+ desktopAppVersion: '1.4.191',
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
})
expect(sendRequest).toHaveBeenCalledOnce()
+ expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191')
} finally {
renderer?.unmount()
}
diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts
index f9837a8ac17..91f0205a5c7 100644
--- a/mobile/src/transport/host-status-gates.ts
+++ b/mobile/src/transport/host-status-gates.ts
@@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
+import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
export type HostStatusGates = {
hostCapabilities: string[]
floatingWorkspaceEnabled: boolean
+ desktopAppVersion: string | null
compatVerdict: CompatVerdict
statusPending: boolean
}
@@ -53,6 +55,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
+ desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
return
@@ -64,9 +67,14 @@ export function useHostStatusGates(args: {
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
+ const desktopAppVersion = normalizeHostAppVersion(status.appVersion)
+ if (hostId && desktopAppVersion) {
+ void recordHostAppVersion(hostId, desktopAppVersion)
+ }
settle({
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
+ desktopAppVersion,
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
@@ -84,6 +92,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
+ desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
}
@@ -100,6 +109,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
+ desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
statusPending: connState === 'connected' && client !== null
}
@@ -107,6 +117,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: proven.hostCapabilities,
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
+ desktopAppVersion: proven.desktopAppVersion,
compatVerdict: proven.compatVerdict,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts
index 76ba92547e0..8ee8df6948e 100644
--- a/mobile/src/transport/mobile-endpoint-lifecycle.ts
+++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts
@@ -93,7 +93,8 @@ function createSupervisor(
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
- desktopPublicKeyB64: host.publicKeyB64
+ desktopPublicKeyB64: host.publicKeyB64,
+ onLog
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts
index 1bce6800ea8..aeb9cddef63 100644
--- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts
+++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts
@@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => {
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
.mockImplementation(() => new FakeRelaySession('connected'))
+ const onLog = vi.fn()
const deps = dependencies({
openRelay,
+ onLog,
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getPendingPath()).toBe('relay')
+ expect(onLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'relay-session-failed',
+ path: 'relay',
+ message: 'Relay: active relay session failed'
+ })
+ )
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts
index ac41e748954..6fca6c0cc17 100644
--- a/mobile/src/transport/mobile-endpoint-supervisor.ts
+++ b/mobile/src/transport/mobile-endpoint-supervisor.ts
@@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason, HostProfile } from './types'
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
+import {
+ logRelayConnected,
+ logRelayCredentialUnavailable,
+ logRelayDialFailure
+} from './mobile-relay-diagnostic-log'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
@@ -98,7 +103,7 @@ export class MobileEndpointSupervisor {
recordMigration: () => {
this.relayRotationPending = false
this.hysteresis.recordMigration(dependencies.now())
- this.logRelay('runtime channel migrated to relay')
+ logRelayConnected(this.logRelay)
},
scheduleLease: (expiry) =>
this.leaseRotation.scheduleFromLease(
@@ -107,8 +112,7 @@ export class MobileEndpointSupervisor {
scheduleDirectProbe: () => this.directProbe.schedule(),
onBookkeepingError: (error) =>
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
- onDialFailure: (error) =>
- this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
+ onDialFailure: (error) => logRelayDialFailure(this.logRelay, error)
})
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
@@ -166,7 +170,8 @@ export class MobileEndpointSupervisor {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
- this.relayReconnect.handleStateFailure(this.logical, state)
+ const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state)
+ logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
}
})
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
@@ -249,11 +254,7 @@ export class MobileEndpointSupervisor {
this.logical.setRecoveryPath(null)
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
// (needs re-pair or LAN) from a Keychain failure in field reports.
- this.logRelay(
- selection.bundle
- ? 'relay credential expired or rejected; slow reprobe armed'
- : 'no relay credential bundle; slow reprobe armed'
- )
+ logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null)
this.relayReconnect.armCredentialReprobe()
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
diff --git a/mobile/src/transport/mobile-relay-diagnostic-log.ts b/mobile/src/transport/mobile-relay-diagnostic-log.ts
new file mode 100644
index 00000000000..0014294cc24
--- /dev/null
+++ b/mobile/src/transport/mobile-relay-diagnostic-log.ts
@@ -0,0 +1,38 @@
+import type { RelayRecoveryLog } from './mobile-relay-recovery-log'
+import { RelayDirectorHttpError } from './mobile-relay-resume-director'
+
+export function logRelayConnected(log: RelayRecoveryLog): void {
+ log('runtime channel migrated to relay', undefined, {
+ level: 'success',
+ code: 'relay-connected'
+ })
+}
+
+export function logRelayDialFailure(
+ log: RelayRecoveryLog,
+ error: Error | null,
+ source: 'dial' | 'active-session' = 'dial'
+): void {
+ if (!error) {
+ return
+ }
+ const base = `${error.name}: ${String(error.message).slice(0, 80)}`
+ const detail =
+ error instanceof RelayDirectorHttpError && error.retryAfterMs != null
+ ? `${base}; retry-after=${error.retryAfterMs}ms`
+ : base
+ log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, {
+ level: 'error',
+ code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed'
+ })
+}
+
+export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void {
+ log(
+ hasBundle
+ ? 'relay credential expired or rejected; slow reprobe armed'
+ : 'no relay credential bundle; slow reprobe armed',
+ undefined,
+ { level: 'warn', code: 'relay-credential-unavailable' }
+ )
+}
diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts
index 2d99e6b1d27..a606abbe9b4 100644
--- a/mobile/src/transport/mobile-relay-reconnect-controller.ts
+++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts
@@ -46,9 +46,7 @@ export class RelayReconnectController {
this.credentials = new RelayCredentialEligibility(dependencies.now)
}
- getFailureCount(): number {
- return this.failureCount.current()
- }
+ getFailureCount = (): number => this.failureCount.current()
reportRecoveryTo(logical: StableLogicalRpcClient): void {
this.failureCount.reportTo(logical.setRecoveryAttempt)
@@ -97,17 +95,17 @@ export class RelayReconnectController {
return 'recover'
}
- handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
+ handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null {
if (!this.needsRecovery(state)) {
- return
+ return null
}
- this.registerActiveFailure(logical)
+ const failure = this.registerActiveFailure(logical)
this.onRetry()
+ return failure
}
- needsRecovery(state: ConnectionState): boolean {
- return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
- }
+ needsRecovery = (state: ConnectionState): boolean =>
+ state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
suspendActiveRelay(logical: StableLogicalRpcClient): void {
if (logical.getActivePath() !== 'relay') {
@@ -207,9 +205,9 @@ export class RelayReconnectController {
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
- registerActiveFailure(logical: StableLogicalRpcClient): void {
+ registerActiveFailure(logical: StableLogicalRpcClient): Error | null {
if (logical.getActivePath() !== 'relay') {
- return
+ return null
}
const failure = this.activeSession?.getFailure()
this.activeSession = null
@@ -219,6 +217,7 @@ export class RelayReconnectController {
} else {
this.activeRelayConnectedAt = null
}
+ return failure ?? null
}
// True when the caller is still inside the cooldown window and must not
diff --git a/mobile/src/transport/mobile-relay-recovery-log.test.ts b/mobile/src/transport/mobile-relay-recovery-log.test.ts
new file mode 100644
index 00000000000..e24173629b3
--- /dev/null
+++ b/mobile/src/transport/mobile-relay-recovery-log.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createRelayRecoveryLog } from './mobile-relay-recovery-log'
+import type { ConnectionLogEntry } from './types'
+
+describe('createRelayRecoveryLog', () => {
+ it('uses distinct ids across logger instances', () => {
+ const entries: ConnectionLogEntry[] = []
+ const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry))
+ createRelayRecoveryLog(() => 1, sink)('first')
+ createRelayRecoveryLog(() => 1, sink)('second')
+
+ expect(entries[0]?.id).not.toBe(entries[1]?.id)
+ })
+})
diff --git a/mobile/src/transport/mobile-relay-recovery-log.ts b/mobile/src/transport/mobile-relay-recovery-log.ts
index 9b373cd112a..21ad9dd63ad 100644
--- a/mobile/src/transport/mobile-relay-recovery-log.ts
+++ b/mobile/src/transport/mobile-relay-recovery-log.ts
@@ -1,6 +1,12 @@
-import type { ConnectionLogSink } from './types'
+import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types'
-export type RelayRecoveryLog = (message: string, detail?: string) => void
+export type RelayRecoveryLog = (
+ message: string,
+ detail?: string,
+ evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode }
+) => void
+
+let relayLoggerInstanceSequence = 0
// Why: relay recovery failed silently in production for weeks; every decision
// must reach logcat and the in-app connection log.
@@ -9,12 +15,15 @@ export function createRelayRecoveryLog(
onLog?: ConnectionLogSink
): RelayRecoveryLog {
let sequence = 0
- return (message, detail) => {
+ const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}`
+ return (message, detail, evidence) => {
console.log(`[relay] ${message}`, detail ?? '')
onLog?.({
- id: `relay-${++sequence}`,
+ id: `relay-${instanceId}-${++sequence}`,
ts: now(),
- level: 'info',
+ level: evidence?.level ?? 'info',
+ path: 'relay',
+ ...(evidence?.code ? { code: evidence.code } : {}),
message: `Relay: ${message}`,
...(detail ? { detail } : {})
})
diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts
index b7073075b2b..a3885a226c0 100644
--- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts
@@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
+import type { ConnectionLogSink } from './types'
const relay = {
v: 1 as const,
@@ -31,7 +32,7 @@ const relay = {
e2eeFraming: 2 as const
}
-async function authenticateSession() {
+async function authenticateSession(onLog?: ConnectionLogSink) {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
@@ -39,7 +40,8 @@ async function authenticateSession() {
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
- requestTimeoutMs: 30_000
+ requestTimeoutMs: 30_000,
+ onLog
})
fakes.linkOptions!.onHello({
type: 'relay-hello',
@@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => {
})
it('disconnects after two fair foreground misses', async () => {
- const session = await authenticateSession()
+ const onLog = vi.fn()
+ const session = await authenticateSession(onLog)
session.notifyForeground('focus')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
@@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => {
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
+ expect(onLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'liveness-timeout',
+ path: 'relay',
+ message: 'Relay health check failed',
+ detail: expect.stringMatching(
+ /^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/
+ )
+ })
+ )
+ })
+
+ it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => {
+ vi.setSystemTime(1_000)
+ const firstLog = vi.fn()
+ const first = await authenticateSession(firstLog)
+ first.notifyForeground('focus')
+ await vi.advanceTimersByTimeAsync(8_000)
+ const firstId = firstLog.mock.calls[0]?.[0].id
+
+ vi.clearAllMocks()
+ fakes.sendText.mockReturnValue(true)
+ vi.setSystemTime(1_000)
+ const secondLog = vi.fn()
+ const second = await authenticateSession(secondLog)
+ second.notifyForeground('focus')
+ await vi.advanceTimersByTimeAsync(8_000)
+ const secondId = secondLog.mock.calls[0]?.[0].id
+
+ expect(firstId).toBeTruthy()
+ expect(secondId).toBeTruthy()
+ expect(secondId).not.toBe(firstId)
})
it('rate-limits foreground sequences without suppressing a retry', async () => {
diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts
index 84b1e7cdbeb..f242fce07ba 100644
--- a/mobile/src/transport/mobile-relay-rpc-session.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session.ts
@@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
import { isRpcResponse } from './rpc-response-shape'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import type { RpcClient } from './rpc-client'
-import type { ConnectionState, RpcResponse } from './types'
+import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
+let relayRpcSessionSequence = 0
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
+ onLog?: ConnectionLogSink
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const pending = new Map()
@@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: {
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
+ let logSequence = 0
+ const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
const livenessIdentity = {}
const streams = new MobileRelayRpcStreams({
nextId,
@@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: {
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
+ onTimeout: (evidence) => {
+ args.onLog?.({
+ id: `relay-liveness-${logSessionId}-${++logSequence}`,
+ ts: Date.now(),
+ level: 'error',
+ code: 'liveness-timeout',
+ path: 'relay',
+ message: 'Relay health check failed',
+ detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
+ })
+ },
terminate: () => fail(new Error('relay session liveness timeout'))
})
return client
diff --git a/mobile/src/transport/persisted-connection-log-store.test.ts b/mobile/src/transport/persisted-connection-log-store.test.ts
new file mode 100644
index 00000000000..6635ccec696
--- /dev/null
+++ b/mobile/src/transport/persisted-connection-log-store.test.ts
@@ -0,0 +1,57 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ConnectionLogEntry } from './types'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(),
+ setItem: vi.fn()
+ }
+}))
+
+describe('persisted connection log store', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.resetModules()
+ })
+
+ it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
+ const stored: ConnectionLogEntry[] = [
+ {
+ id: 'client-session-1000',
+ ts: 1_000,
+ level: 'info',
+ code: 'client-session-started',
+ message: 'Mobile client session started'
+ },
+ {
+ id: 'relay-failure',
+ ts: 1_000,
+ level: 'error',
+ code: 'relay-session-failed',
+ message: 'Relay: active relay session failed'
+ }
+ ]
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
+ vi.resetModules()
+ const { connectionLogStore, recordConnectionClientSessionStart } =
+ await import('./persisted-connection-log-store')
+
+ recordConnectionClientSessionStart('host-a')
+ await connectionLogStore.hydrate('host-a')
+
+ expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
+ 'client-session-started',
+ 'relay-session-failed',
+ 'client-session-started'
+ ])
+ })
+})
diff --git a/mobile/src/transport/persisted-connection-log-store.ts b/mobile/src/transport/persisted-connection-log-store.ts
new file mode 100644
index 00000000000..85f9794b284
--- /dev/null
+++ b/mobile/src/transport/persisted-connection-log-store.ts
@@ -0,0 +1,77 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { createConnectionLogStore } from './connection-log-buffer'
+import type { ConnectionLogEntry } from './types'
+
+const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
+const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
+const sessionStartedHosts = new Set()
+
+export const connectionLogStore = createConnectionLogStore(200, {
+ async load(hostId) {
+ const raw = await AsyncStorage.getItem(storageKey(hostId))
+ if (!raw) {
+ return []
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw)
+ return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : []
+ } catch {
+ return []
+ }
+ },
+ save(hostId, entries) {
+ return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries))
+ }
+})
+
+export function recordConnectionRevival(
+ hostId: string,
+ reason: 'app-resume' | 'network-change'
+): void {
+ const now = Date.now()
+ connectionLogStore.append(hostId, {
+ id: `revival-${reason}-${now}`,
+ ts: now,
+ level: 'info',
+ code: reason === 'app-resume' ? 'app-resumed' : 'network-changed',
+ message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed',
+ detail: 'Connection recovery notified'
+ })
+}
+
+export function recordConnectionClientSessionStart(hostId: string): void {
+ if (sessionStartedHosts.has(hostId)) {
+ return
+ }
+ sessionStartedHosts.add(hostId)
+ const now = Date.now()
+ connectionLogStore.append(hostId, {
+ id: `client-session-${clientSessionId}-${now}`,
+ ts: now,
+ level: 'info',
+ code: 'client-session-started',
+ message: 'Mobile client session started'
+ })
+}
+
+function storageKey(hostId: string): string {
+ return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}`
+}
+
+function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+ const entry = value as Partial
+ return (
+ typeof entry.id === 'string' &&
+ typeof entry.ts === 'number' &&
+ Number.isFinite(entry.ts) &&
+ (entry.level === 'info' ||
+ entry.level === 'success' ||
+ entry.level === 'warn' ||
+ entry.level === 'error') &&
+ typeof entry.message === 'string' &&
+ (entry.detail === undefined || typeof entry.detail === 'string')
+ )
+}
diff --git a/mobile/src/transport/rpc-client-socket-close-controller.ts b/mobile/src/transport/rpc-client-socket-close-controller.ts
index a03096757c6..65e65951954 100644
--- a/mobile/src/transport/rpc-client-socket-close-controller.ts
+++ b/mobile/src/transport/rpc-client-socket-close-controller.ts
@@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
import type { RpcClientSocketSession } from './rpc-client-socket-session'
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
+import type { ConnectionLogEntry } from './types'
const UNAUTHORIZED_CLOSE_CODE = 4001
@@ -21,7 +22,11 @@ type SocketCloseControllerOptions = {
getAuthenticationGeneration: () => number
isIntentionallyClosed: () => boolean
stopLiveness: (session: RpcClientSocketSession) => void
- emitWarning: (message: string, detail: string) => void
+ emitWarning: (
+ message: string,
+ detail: string,
+ evidence?: Pick
+ ) => void
}
export class RpcClientSocketCloseController {
@@ -80,7 +85,11 @@ export class RpcClientSocketCloseController {
streamCount: this.options.streams.size(),
attempt: this.options.reconnect.getAttempt()
})
- this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
+ this.options.emitWarning(
+ 'WebSocket closed',
+ `${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`,
+ { code: 'socket-closed' }
+ )
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
this.options.connectionState.publish('reconnecting')
this.options.reconnect.schedule()
diff --git a/mobile/src/transport/rpc-client-socket-factory.ts b/mobile/src/transport/rpc-client-socket-factory.ts
index 1c21c0dd4e0..df82e460d92 100644
--- a/mobile/src/transport/rpc-client-socket-factory.ts
+++ b/mobile/src/transport/rpc-client-socket-factory.ts
@@ -1,7 +1,7 @@
import { publicKeyFromBase64 } from './e2ee'
import { RpcClientSocketSession } from './rpc-client-socket-session'
import { redactSocketEndpoint } from './socket-event-debug'
-import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
+import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
type SocketFactoryOptions = {
endpoint: string
@@ -12,7 +12,7 @@ type SocketFactoryOptions = {
getReconnectAttempt: () => number
getLastConnectedAt: () => number | null
isIntentionallyClosed: () => boolean
- emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
+ emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
diff --git a/mobile/src/transport/rpc-client-socket-session.ts b/mobile/src/transport/rpc-client-socket-session.ts
index 6eebc6b02b9..bc8f9d13e2c 100644
--- a/mobile/src/transport/rpc-client-socket-session.ts
+++ b/mobile/src/transport/rpc-client-socket-session.ts
@@ -9,7 +9,7 @@ import {
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
-import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
+import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
const CONNECT_TIMEOUT_MS = 12_000
@@ -24,7 +24,7 @@ type SocketSessionOptions = {
getState: () => ConnectionState
getReconnectAttempt: () => number
isIntentionallyClosed: () => boolean
- emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
+ emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
@@ -261,7 +261,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'WebSocket connect timeout',
- `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
+ `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
+ { code: 'connect-timeout' }
)
this.options.onForcedClose(this)
}
@@ -280,7 +281,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'Handshake timeout',
- `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
+ `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
+ { code: 'handshake-timeout' }
)
this.options.onForcedClose(this)
}, HANDSHAKE_TIMEOUT_MS)
diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts
index c3aab2a243f..aa1398e44b6 100644
--- a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts
+++ b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts
@@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => {
expect(terminate).toHaveBeenCalledWith(identity)
})
+ it('reports causal evidence before terminating a stale session', async () => {
+ const onTimeout = vi.fn()
+ const terminate = vi.fn()
+ const identity = {}
+ const watchdog = new RpcSessionLivenessWatchdog({
+ transport: 'relay',
+ idleProbeMs: null,
+ probeTimeoutMs: 4_000,
+ missedProbeLimit: 2,
+ sendProbe: () => true,
+ terminate,
+ onTimeout,
+ now: Date.now
+ })
+ watchdog.start(identity)
+ watchdog.probeNow(identity)
+
+ await vi.advanceTimersByTimeAsync(8_000)
+
+ expect(onTimeout).toHaveBeenCalledWith({
+ transport: 'relay',
+ reason: 'probe-timeout',
+ missedProbes: 2,
+ missedProbeLimit: 2,
+ lastInboundAgeMs: 8_000
+ })
+ expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan(
+ terminate.mock.invocationCallOrder[0]!
+ )
+ })
+
it('authenticated activity resets suspicion', async () => {
const { identity, terminate, watchdog } = fixture()
watchdog.probeNow(identity)
diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts
index e819dbb6edf..36525f60fb0 100644
--- a/mobile/src/transport/rpc-session-liveness-watchdog.ts
+++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts
@@ -8,6 +8,7 @@ type WatchdogOptions = {
transport: 'direct' | 'relay'
sendProbe: (identity: RpcSessionIdentity) => boolean
terminate: (identity: RpcSessionIdentity) => void
+ onTimeout?: (evidence: LivenessTimeoutEvidence) => void
idleProbeMs?: number | null
probeTimeoutMs?: number
missedProbeLimit?: number
@@ -17,6 +18,14 @@ type WatchdogOptions = {
clearTimer?: typeof clearTimeout
}
+export type LivenessTimeoutEvidence = {
+ transport: 'direct' | 'relay'
+ reason: 'probe-send-failed' | 'probe-timeout'
+ missedProbes: number
+ missedProbeLimit: number
+ lastInboundAgeMs: number
+}
+
export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType | null = null
@@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog {
sent = false
}
if (!sent) {
- this.terminateCurrent(identity)
+ this.terminateCurrent(identity, 'probe-send-failed')
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
@@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog {
}
this.missedProbes += 1
if (this.missedProbes >= this.missedProbeLimit) {
- this.terminateCurrent(identity)
+ this.terminateCurrent(identity, 'probe-timeout')
return
}
console.log('[net] activity-probe timeout tolerated', {
@@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog {
this.startProbe(identity)
}
- private terminateCurrent(identity: RpcSessionIdentity): void {
+ private terminateCurrent(
+ identity: RpcSessionIdentity,
+ reason: LivenessTimeoutEvidence['reason']
+ ): void {
if (this.identity !== identity) {
return
}
@@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog {
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit
})
+ this.options.onTimeout?.({
+ transport: this.options.transport,
+ reason,
+ missedProbes: this.missedProbes,
+ missedProbeLimit: this.missedProbeLimit,
+ lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
+ })
this.options.terminate(identity)
}
diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts
index 9d96f0561ad..c40bd979936 100644
--- a/mobile/src/transport/types.ts
+++ b/mobile/src/transport/types.ts
@@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
+export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay'
+
+export type ConnectionDiagnosticCode =
+ | 'client-session-started'
+ | 'app-resumed'
+ | 'network-changed'
+ | 'connect-timeout'
+ | 'handshake-timeout'
+ | 'authentication-rejected'
+ | 'socket-closed'
+ | 'liveness-timeout'
+ | 'retry-scheduled'
+ | 'relay-dial-failed'
+ | 'relay-session-failed'
+ | 'relay-connected'
+ | 'direct-connected'
+ | 'relay-credential-unavailable'
+ | 'host-open-failed'
+
export type ConnectionLogEntry = {
id: string
ts: number
@@ -47,10 +66,19 @@ export type ConnectionLogEntry = {
message: string
// Optional second line for endpoint/error/elapsed detail.
detail?: string
+ code?: ConnectionDiagnosticCode
+ path?: MobileConnectionDiagnosticPath
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
+export type ConnectionLogEmitter = (
+ level: ConnectionLogLevel,
+ message: string,
+ detail?: string,
+ evidence?: Pick
+) => void
+
export type ConnectionState =
| 'connecting'
| 'handshaking'
diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts
index ac957638db6..03ac5890015 100644
--- a/mobile/src/transport/use-all-host-clients.ts
+++ b/mobile/src/transport/use-all-host-clients.ts
@@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
-import type { HostClientAcquisition } from './client-context'
+import type { HostClientAcquisition } from './host-client-acquisition-registry'
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts
index 037e4dcfa87..2884c95e87a 100644
--- a/mobile/src/worktree/host-worktree-rpc-types.ts
+++ b/mobile/src/worktree/host-worktree-rpc-types.ts
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
// Locally-typed subset of the desktop status payload read from status.get.
export type DesktopStatus = {
+ appVersion?: string
protocolVersion?: number
minCompatibleMobileVersion?: number
// Why: absent on hosts that predate the mobile Floating Workspace entry;
diff --git a/package.json b/package.json
index 7b46b369406..c8e59299c70 100644
--- a/package.json
+++ b/package.json
@@ -111,6 +111,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
+ "test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs",
"test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs",
"test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0d80c024dff..2c36d26ae93 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
- hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
+ hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
@@ -18,7 +18,7 @@ patchedDependencies:
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
- hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
+ hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
@@ -27,7 +27,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
- hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
+ hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -69,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
- version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
+ version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -229,7 +229,7 @@ importers:
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
- version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
+ version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -407,7 +407,7 @@ importers:
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
- version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
+ version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -9605,7 +9605,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
- '@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
+ '@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)':
dependencies:
node-addon-api: 7.1.0
optional: true
@@ -9638,7 +9638,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
- '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
+ '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -12046,7 +12046,7 @@ snapshots:
node-int64@0.4.0: {}
- node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
+ node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
dependencies:
node-addon-api: 7.1.1
diff --git a/skill-guides/orca-emulator-android.md b/skill-guides/orca-emulator-android.md
index e1372d62796..6c24b515a5f 100644
--- a/skill-guides/orca-emulator-android.md
+++ b/skill-guides/orca-emulator-android.md
@@ -89,20 +89,20 @@ issues `adb shell input` events; AVD names resolve to running adb serials.
Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
(top-left origin) — never pixels; Orca converts using the live screen size.
-| Goal | Command | Notes |
-|----------------------------|----------------------------------------------------------------|-------|
-| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
-| Single tap | `ORCA emulator tap --device ` | Normalized 0..1. Preferred for single taps. |
-| Swipe / gesture | `ORCA emulator gesture '' --device ` | adb approximates the path by its endpoints (start→end). |
-| Type text | `ORCA emulator type "user@example.com" --device ` | US ASCII; spaces handled. No newlines. |
-| Hardware button | `ORCA emulator button back --device ` | home, back, recents, power, volume_up, volume_down. |
-| Rotate | `ORCA emulator rotate landscape_left --device ` | Sets user_rotation (disables auto-rotate). |
-| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device ` | `--reinstall` passes `-r`. |
-| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device ` | Omit `--activity` to launch the default LAUNCHER activity. |
-| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device ` | grant / revoke / reset. |
-| Accessibility tree | `ORCA emulator ax --device --json` | `uiautomator dump` parsed to a node tree. |
-| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device ` | Dumps recent lines; parsed to entries. |
-| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device ` | Runs `adb -s shell `. |
+| Goal | Command | Notes |
+| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
+| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
+| Single tap | `ORCA emulator tap --device ` | Normalized 0..1. Preferred for single taps. |
+| Swipe / gesture | `ORCA emulator gesture '' --device ` | adb approximates the path by its endpoints (start→end). |
+| Type text | `ORCA emulator type "user@example.com" --device ` | US ASCII; spaces handled. No newlines. |
+| Hardware button | `ORCA emulator button back --device ` | home, back, recents, power, volume_up, volume_down. |
+| Rotate | `ORCA emulator rotate landscape_left --device ` | Sets user_rotation (disables auto-rotate). |
+| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device ` | `--reinstall` passes `-r`. |
+| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device ` | Omit `--activity` to launch the default LAUNCHER activity. |
+| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device ` | grant / revoke / reset. |
+| Accessibility tree | `ORCA emulator ax --device --json` | `uiautomator dump` parsed to a node tree. |
+| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device ` | Dumps recent lines; parsed to entries. |
+| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device ` | Runs `adb -s shell `. |
## Critical gotchas (teach agents)
diff --git a/skill-guides/orca-emulator.md b/skill-guides/orca-emulator.md
index 139577b1cdb..73c12fd05eb 100644
--- a/skill-guides/orca-emulator.md
+++ b/skill-guides/orca-emulator.md
@@ -36,6 +36,7 @@ shell-neutral for POSIX shells, PowerShell, and cmd.exe.
- The agent should use Orca's preview pane instead of external Simulator.app or raw serve-sim URLs.
**When NOT to use**
+
- Android emulators → use the `orca-emulator-android` skill (same `ORCA emulator` namespace, cross-platform via adb/emulator).
- Building or installing the app itself → use `xcodebuild`, `xcrun simctl install`, `expo run:ios`, etc. (launch the app, then use `ORCA emulator` to drive it).
- In-app debugging (state, network, views) → use the app's own tools or the browser pane if it's a webview.
@@ -75,6 +76,7 @@ An active emulator "session" for the worktree is required for most commands. Use
```
Orca owns:
+
- Starting/stopping the serve-sim helper (via --detach or direct).
- Per-worktree "active" emulator (like active browser tab).
- Explicit targeting with `--worktree`, `--device`, `--emulator `.
@@ -82,26 +84,26 @@ Orca owns:
Agents use the Orca executable chosen above (on PATH in Orca terminals) and never have to manage PIDs, state files in /tmp, or raw WS URLs themselves.
-**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at *this* worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
+**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at _this_ worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
## Common operations
Use `--json` for agent-friendly output. Commands are workspace-scoped by default (current worktree's active emulator).
-| Goal | Command | Notes |
-|-----------------------------|----------------------------------------------|-------|
-| List available / running | `ORCA emulator list [--worktree ]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
-| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree ] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
-| Single tap | `ORCA emulator tap [--device ]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
-| Multi-step gesture | `ORCA emulator gesture ''` | See gestures reference (begin/move/end). Use tap for singles. |
-| Type text | `ORCA emulator type "text" [--device ]` | US ASCII only. Supports stdin/file via exec if needed. |
-| Hardware button | `ORCA emulator button home [--device ]` | home, swipe_home, app_switcher, lock, siri, side_button. |
-| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
-| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
-| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
-| Accessibility tree | `ORCA emulator ax [--device ]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
-| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
-| Stop | `ORCA emulator kill [--device ]` | Or let pane close / Orca quit clean up. |
+| Goal | Command | Notes |
+| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| List available / running | `ORCA emulator list [--worktree ]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
+| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree ] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
+| Single tap | `ORCA emulator tap [--device ]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
+| Multi-step gesture | `ORCA emulator gesture ''` | See gestures reference (begin/move/end). Use tap for singles. |
+| Type text | `ORCA emulator type "text" [--device ]` | US ASCII only. Supports stdin/file via exec if needed. |
+| Hardware button | `ORCA emulator button home [--device ]` | home, swipe_home, app_switcher, lock, siri, side_button. |
+| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
+| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
+| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
+| Accessibility tree | `ORCA emulator ax [--device ]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
+| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
+| Stop | `ORCA emulator kill [--device ]` | Or let pane close / Orca quit clean up. |
Most support `--worktree ` and explicit `--device ` or `--emulator ` (from list) for targeting.
diff --git a/skill-guides/orca-per-workspace-env.md b/skill-guides/orca-per-workspace-env.md
index 77c96d12dfc..bd4cd869c54 100644
--- a/skill-guides/orca-per-workspace-env.md
+++ b/skill-guides/orca-per-workspace-env.md
@@ -76,7 +76,7 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
- **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
- **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
- token`; §5).
+token`; §5).
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
place before any paid step.
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
@@ -135,6 +135,13 @@ shape is §7a; key points:
- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).
- Clone with the git token via `GIT_ASKPASS` (§5).
- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.
+- **Never snapshot a machine on which the Orca runtime has already run.** The first `orca serve` creates
+ the runtime's user-data dir, and everything in it gets baked into the image and shared by every VM
+ booted from it: the pairing keypair and device-token registry (`orca-devices.json`,
+ `orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history
+ and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and
+ `pairedDeviceId`**. Snapshot **before** the runtime has ever run, or `rm -rf` the whole user-data dir
+ (`~/.config/orca` on Linux) first — deleting a named file list will drift as Orca adds state.
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.
---
@@ -163,6 +170,10 @@ their own terminal, or via the Claude Code harness bang-prefix (`! `, with
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.
+This layer inherits §3's rule: if you started `orca serve` on the base or auth sandbox to smoke-test it,
+delete the runtime's user-data dir (`~/.config/orca` on Linux) before re-snapshotting, or every workspace
+booted from this image shares one pairing identity and one `agent-session-authority.key`.
+
If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).
For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
@@ -221,9 +232,9 @@ reserve stdout for the final JSON and log progress to stderr. Include a shared `
- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
- bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
+bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
-- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
+- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so
bash is fine there regardless of the user's OS.
### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2
@@ -303,7 +314,11 @@ There is **no `--host` flag**. `--project-root` must be an absolute directory on
keeps serving:
```json
-{ "schemaVersion": 1, "pairingCode": "", "projectRoot": "" }
+{
+ "schemaVersion": 1,
+ "pairingCode": "",
+ "projectRoot": ""
+}
```
`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
@@ -498,7 +513,7 @@ git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
-**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
+**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no
`orca serve` URL in SSH mode):
- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
@@ -511,7 +526,7 @@ Fail if the requested schema is not `2`; do not silently fall back to the ordina
reconnect grace window.
**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
-recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
+recipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and
the §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.
`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.
@@ -615,7 +630,7 @@ $ErrorActionPreference = 'Stop'
# progress/errors → Write-Error / the error stream, never stdout.
```
-The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.
+The remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.
---
@@ -702,10 +717,10 @@ each stage so you can self-diagnose without asking the user to relay logs:
```json
{
"ok": false,
- "checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
+ "checks": [{ "id": "recipe.provision", "status": "fail", "message": "…" }],
"provisionTranscript": {
"provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
- "destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
+ "destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
}
}
```
@@ -743,7 +758,7 @@ startup-only `docker run` before the full clone/install path.
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
- 'logged in'`, which also matches "not logged in".
+'logged in'`, which also matches "not logged in".
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
URL + code the user opens on the host.
diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts
index 4385841d2f8..327581b9d69 100644
--- a/src/cli/bundled-skill-guides.ts
+++ b/src/cli/bundled-skill-guides.ts
@@ -18,16 +18,16 @@ const LINEAR_TICKETS_MARKDOWN = "---\nname: linear-tickets\ndescription: >-\n U
const ORCA_CLI_MARKDOWN = "---\nname: orca-cli\ndescription: >-\n Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,\n terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser\n embedded inside the Orca app. Use when the user says \"$orca-cli\", \"use orca cli\",\n \"Orca worktree\", \"child worktree\", \"cardStatus\", \"spawn codex/claude in a worktree\",\n \"read/wait/send Orca terminal\", \"terminal send\", \"full handoff\", \"handover\",\n \"give this to another agent\", \"another worktree\", \"Orca browser\", \"orca artifacts\",\n \"share HTML/Markdown\", \"public artifact link\", \"share skills\", or \"control the browser inside\n Orca\". Prefer this over raw `git worktree`, ad hoc\n PTYs, Playwright, or Computer Use when the task touches Orca-managed state.\n Use Computer Use for browser windows, webviews, or desktop UI outside Orca's\n embedded browser.\n---\n\n# Orca CLI\n\nUse `orca` when Orca's running editor/runtime is the source of truth. Inside Orca-managed terminals, `orca` always resolves to the Orca CLI on every platform. In any other shell on Linux, use `orca-ide` wherever this file says `orca` — outside Orca's terminals, bare `orca` on Linux is usually the GNOME Orca screen reader (`/usr/bin/orca`), and running it starts speech on the user's machine.\n\n**Dev builds (`pnpm dev`):** after `pnpm build:cli`, the dev CLI is exposed as `orca-dev` (the global shim points at this checkout's wrapper + out/cli). Inside a dev Orca's terminals use `orca-dev emulator ...` (or `./config/scripts/orca-dev.mjs emulator ...` for worktree-local invocation that does not depend on the /usr/local/bin symlink). Plain `orca` targets any installed production Orca. The app's own agent preambles use `orca-dev` automatically in dev mode.\n\nUse plain shell tools when Orca state does not matter.\n\n## Start Here\n\nChoose the executable once for the current session:\n\n- If the `ORCA_CLI_COMMAND` environment variable is set, use its value. Orca exports this\n for managed WSL sessions.\n- Otherwise, in a dev checkout whose session exposes `ORCA_DEV_REPO_ROOT`, use `orca-dev`.\n- Otherwise, on Linux outside an Orca-managed terminal, use `orca-ide`. Never use bare\n `orca` there because it normally resolves to the GNOME screen reader.\n- Otherwise, use `orca`.\n\nIn every command block, `ORCA` is a documentation placeholder. Replace it with the chosen\nexecutable before running the command; do not create a shell variable or run `ORCA`\nliterally. This substitution works the same way in POSIX shells, PowerShell, and cmd.exe.\n\n```text\nORCA status --json\nORCA worktree ps --json\nORCA terminal list --json\n```\n\nKeep using that same executable for every later command so dev sessions do not reach a\nproduction CLI and Linux never falls through to the GNOME screen reader.\n\nIf Orca is not running, start it:\n\n```text\nORCA open --json\nORCA status --json\n```\n\nPrefer `--json` for agent-driven calls. If the CLI is missing, say so explicitly instead of inspecting source files first.\n\n## Full Handoffs\n\nA full handoff transfers ownership to another agent or worktree, then the original agent stops. Treat requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs unless the user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use decision gates, or manage ask/reply.\n\nDo not use `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Deliver the prompt with worktree/terminal commands, report the created worktree/terminal if useful, and stop monitoring.\n\nIndependent new-worktree handoff:\n\n```text\nORCA worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nUse `--no-parent` and omit `--base-branch` for independent top-level handoffs unless the user explicitly asks for stacked work, \"branch from current\", or a specific base. Put any current-branch context in the prompt.\n\nCustom Codex model/effort handoff:\n\n`worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. For requests such as `gpt-5.5 xhigh`, create the independent worktree, launch the requested Codex command there, wait only for TUI readiness if needed to avoid losing input, send the prompt, and stop.\n\n**Extra first terminal:** when no repo default-terminal configuration supplies a primary terminal, bare `worktree create` (no `--agent`) opens a fallback shell before the later `terminal create --command ...` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever the built-in launcher is enough. When custom argv forces the two-step path, target the agent handle only; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nThe create result's `worktree.id` already contains both pieces Orca needs: `::`. Copy that whole value into the next command; do not shorten it to the repo id.\n\n```text\nORCA worktree create --name --no-parent --json\nORCA terminal create --worktree id::: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA terminal send --terminal --text \"\" --enter --json\n```\n\nExisting-terminal handoff:\n\n```text\nORCA terminal send --terminal --text \"\" --enter --json\n```\n\n## Worktrees\n\nAn Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.\n\nThink of its id as a two-part address: `::`. For example, `repo-123::/Users/me/orca/fix-login` means “the `fix-login` checkout inside repo `repo-123`.” Always copy the complete `id` field from `orca worktree create --json` or `orca worktree list --json`; `repo-123` alone identifies only the repo.\n\nCommon commands:\n\n```text\nORCA repo list --json\nORCA repo show --repo id: --json\nORCA repo add --path /abs/repo --json\nORCA repo set-base-ref --repo id: --ref origin/main --json\nORCA repo search-refs --repo id: --query main --limit 10 --json\nORCA worktree list --repo id: --json\nORCA worktree ps --json\nORCA worktree current --json\nORCA worktree show --worktree --json\nORCA worktree create --repo id: --name related-task --json\nORCA worktree create --repo id: --name related-task --parent-worktree active --json\nORCA worktree create --repo id: --name folder-child --parent-worktree folder: --json\nORCA worktree create --name child-task --agent codex --prompt \"hi\" --json\nORCA worktree create --name independent-task --no-parent --json\nORCA worktree set --worktree id::: --display-name \"My Task\" --json\nORCA worktree set --worktree active --comment \"reproduced bug; testing fix\" --json\nORCA worktree set --worktree active --workspace-status in-review --json\nORCA worktree rm --worktree id::: --force --json\n```\n\nSelectors:\n\n- `id:::`, `name:`, `path:`, `branch:`, `issue:`\n- The full id is the exact `::` value returned by `orca worktree create --json` or `orca worktree list --json`; a bare repo id is not a worktree id.\n- `active` / `current` for the enclosing Orca-managed worktree from the shell cwd\n- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:`, `worktree:::`, `id:folder:`, `id:worktree:::`\n\nLineage rules:\n\n- When creating from inside an Orca-managed worktree or folder context, Orca infers the current parent context when it can.\n- Use `--parent-worktree active` when the child worktree relationship should be explicit.\n- Use `--parent-worktree folder:` or `--parent-worktree worktree:::` when a folder or worktree parent context should be explicit.\n- Use `--no-parent` only when the new work is independent.\n- `--no-parent` only controls Orca lineage; it does not choose the Git base. For independent top-level work, omit `--base-branch` so Orca uses the repo default base, or explicitly pass the repo default base. Never base it on the current feature branch unless the user asks for stacked work or \"branch from current\".\n- If `--repo` is omitted, Orca infers the repo from the current Orca worktree when possible.\n\nAgent/setup flags:\n\n```text\nORCA worktree create --name task --agent codex --prompt \"hi\" --json\nORCA worktree create --name task --agent claude --setup run --json\nORCA worktree create --name task --setup skip --json\nORCA worktree create --name task --run-hooks --json\n```\n\n- `--agent ` launches that agent **in the first terminal** (Orca docs: _\"`--agent` launches the selected agent in the first terminal\"_); `--prompt ` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.\n- **Prefer agent-first create for agent workers.** `orca worktree create --agent --prompt \"...\"` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command ` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell.\n- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id::: --json` (or `name:`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles.\n- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.\n- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.\n- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.\n- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab.\n- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree --command \"\"` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.\n- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command \"codex\" --json` — that path does not create a second worktree shell.\n\n## Worktree Comments\n\nA worktree comment is the short status text shown in Orca's workspace list/card for quick progress visibility.\n\nCoding agents should update the active worktree comment at meaningful checkpoints:\n\n```text\nORCA worktree set --worktree active --comment \"fix implemented; running integration tests\" --json\n```\n\nUpdate after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested.\n\nCard status uses `--workspace-status `; defaults are `todo`, `in-progress`, `in-review`, `completed`.\n\n## Terminals\n\nCommon commands:\n\n```text\nORCA terminal list --worktree id::: --json\nORCA terminal show --terminal --json\nORCA terminal read --terminal --json\nORCA terminal read --terminal --cursor --limit 1000 --json\nORCA terminal read --json\nORCA terminal send --terminal --text \"continue\" --enter --json\nORCA terminal send --text \"echo hello\" --enter --json\nORCA terminal wait --terminal --for exit --timeout-ms 5000 --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 300000 --json\nORCA terminal stop --worktree id::: --json\nORCA terminal create --json\nORCA terminal create --title \"Worker\" --json\nORCA terminal create --worktree active --command \"codex\" --json\nORCA terminal split --terminal --direction vertical --json\nORCA terminal split --terminal --direction horizontal --command \"npm test\" --json\nORCA terminal rename --terminal --title \"New Name\" --json\nORCA terminal switch --terminal --json\nORCA terminal close --terminal --json\n```\n\nTerminal rules:\n\n- `--terminal` is optional for most commands; omitted means the active terminal in the current worktree.\n- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.\n- Use `terminal read` before `terminal send` unless the next input is obvious.\n- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.\n- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --inject` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.\n- Use `terminal create --worktree active --command \"\"` for a fresh agent in the current worktree. Use `worktree create --agent ` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).\n- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.\n- Terminal handles are runtime-scoped. Use `startupTerminal.handle` as the sole agent handle when `worktree create --agent` returns it; if Orca restarts, omits the handle, or returns `terminal_handle_stale`, reacquire with `terminal list` and continue with the replacement only.\n- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.\n- `--direction horizontal` splits left/right. `--direction vertical` splits top/bottom.\n\n## Automations\n\nAn automation is a scheduled Orca prompt run by a chosen provider against either a repo-created worktree or an existing workspace.\n\n```text\nORCA automations list --json\nORCA automations show --json\nORCA automations create --name \"Daily review\" --trigger daily --time 09:00 --prompt \"Review open changes\" --provider codex --repo id: --json\nORCA automations create --name \"Weekday triage\" --trigger \"0 9 * * 1-5\" --prompt \"Triage issues\" --provider claude --repo path:/abs/repo --disabled --json\nORCA automations create --name \"Inbox digest\" --trigger hourly --prompt \"Summarize unread mail\" --provider codex --workspace active --reuse-session --json\nORCA automations edit --trigger weekdays --time 09:30 --fresh-session --json\nORCA automations run --json\nORCA automations runs --id --json\nORCA automations remove --json\n```\n\nSchedules accept `hourly`, `daily`, `weekdays`, `weekly`, 5-field cron, or RRULE. Use `--time ` with `daily`/`weekdays`/`weekly`, and `--day <0-6>` only with `weekly` where Sunday is `0`.\n\nUse `--repo ` for a new worktree per run, or `--workspace ` / `--workspace-mode existing` for an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive. Use `--reuse-session` only for existing-workspace automations; if the previous terminal is gone, Orca falls back to a fresh session. Prefer `--disabled` while testing setup.\n\n## Artifacts\n\nArtifacts publish HTML or Markdown files through the signed-in Orca account. The public\nshare URL is viewable without signing in; creating, listing, updating, and deleting\nartifacts require the active Orca profile to be signed in.\n\n**Publishing is off by default and only a human can turn it on.** `share` and `update` are\ngated by a device-wide capability that the user grants in the Orca desktop app under\nSettings → Artifacts (\"Allow publishing public artifact links\"). The gate applies to every\ncaller on the device, agent or human. There is no CLI or RPC way to grant it — do not try.\n`list`, `unshare`, and `delete` are never gated, so old links stay auditable and revocable.\n\n`share` and `update` check the capability before reading the file, so a denial costs one\nsmall round trip rather than an upload-sized payload.\n\nWhen a share is denied, the CLI fails with code `artifact_sharing_disabled` and prints the\nrecovery steps. Do not retry — the answer will not change until a human acts. Tell the user\nto open Settings → Artifacts in the Orca desktop app on this device, turn on \"Allow\npublishing public artifact links\", and then re-run the command. If they do not want to grant\nit, deliver the file locally instead.\n\n```text\nORCA artifacts share --json\nORCA artifacts update --json\nORCA artifacts unshare