123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868
// Puppeteer Bridge Server — multi-session persistent browser instances
// Each session gets its own independent Chrome window (not tabs)
// Runs on desktop, controlled via HTTP from container or Adom Desktop app
// Port 8851 by default, configurable via --port
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync, execFile } = require('child_process');
// v1.8.29: run a PowerShell script file with NO console window (windowsHide) and no cmd
// shell (execFileSync directly), so pup's window-management PS never flashes a terminal on
// the user's screen. -WindowStyle Hidden is belt-and-suspenders. Sync variant returns stdout.
function runPsHidden(scriptPath, { timeout = 6000 } = {}) {
  return execFileSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { encoding: 'utf8', timeout, windowsHide: true });
}
// Fire-and-forget async variant (never blocks the driving verb).
function runPsHiddenAsync(scriptPath, { timeout = 6000 } = {}) {
  execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { timeout, windowsHide: true }, () => {});
}
// Promise variant: resolves with stdout (trimmed) or '' on error. For PS whose RESULT matters
// but which must not block the event loop (e.g. the background watchdog).
function runPsHiddenPromise(scriptPath, { timeout = 8000 } = {}) {
  return new Promise(resolve => {
    execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { encoding: 'utf8', timeout, windowsHide: true }, (err, stdout) => {
      resolve((stdout || '').trim());
    });
  });
}
const puppeteer = require('puppeteer');
const credentialVault = require('./credential_vault');
const chrome = require('./chrome'); // Chrome-for-Testing self-heal: detect / auto-install / readiness (cold-start fix)

// sharp is REQUIRED for resizing screenshots — never send full-res to container.
// v1.8.40+: previously this was wrapped in try/catch with a `let sharp = null`
// fallback. The fallback path silently sent 2559px PNGs to /tmp/adom-desktop-screenshots/,
// which CRASHES Claude many-image sessions at the 2000px limit. Hard-require
// sharp is OPTIONAL — a heavy native dep whose install can fail on a fresh box (no
// prebuild / no build tools / a managed-runtime PATH gap). pup must still spawn and
// work without it: we fall back to serving the screenshot at capture size and reading
// dimensions from the PNG header. When sharp IS present, we downscale to keep images
// under Claude's Read limit (the ideal path). Never let a missing sharp crash the bridge.
let sharp = null;
try { sharp = require('sharp'); }
catch (e) { console.warn('[pup] sharp unavailable — screenshots served at capture size, not downscaled:', e.message); }
const MAX_SCREENSHOT_DIM = 1568; // Claude's optimal max dimension

// PNG width/height straight from the IHDR header (bytes 16-23, big-endian). Pure JS,
// no sharp needed. puppeteer screenshots are always PNG.
function pngDimensions(buf) {
  try { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; }
  catch { return { width: 0, height: 0 }; }
}
// Downscale a PNG to <= maxDim (long edge) when sharp is available; otherwise pass it
// through at capture size. Returns the (possibly-resized) buffer + dimensions + flags.
async function processScreenshot(buf, maxDim) {
  if (sharp) {
    const orig = await sharp(buf).metadata();
    const needsResize = orig.width > maxDim || orig.height > maxDim;
    const out = await (needsResize
      ? sharp(buf).resize({ width: maxDim, height: maxDim, fit: 'inside' })
      : sharp(buf)).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();
    const fin = await sharp(out).metadata();
    return { buf: out, width: fin.width, height: fin.height, origWidth: orig.width, origHeight: orig.height, resized: needsResize, sharpUsed: true };
  }
  const d = pngDimensions(buf);
  return { buf, width: d.width, height: d.height, origWidth: d.width, origHeight: d.height, resized: false, sharpUsed: false };
}

const PORT = (() => {
  const idx = process.argv.indexOf('--port');
  return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851;
})();

const BRIDGE_VERSION = (() => { try { return fs.readFileSync(path.join(__dirname, 'BRIDGE_VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();

const SHOTS_DIR = path.join(__dirname, 'screenshots');
fs.mkdirSync(SHOTS_DIR, { recursive: true });

// v1.8.21: shadow-DOM-piercing + nested-scroll helpers, installed into every page's
// window so browser_eval can use them ($deep / $$deep / deepText) AND so browser_scroll
// / browser_screenshot_element resolve elements + the ACTUAL scroll container the same
// way. Idempotent (guarded by __pupDeepInstalled). Injected via evaluateOnNewDocument
// (persists across navigations) AND ensured on-demand before each verb (covers the
// already-loaded document).
const PUP_DEEP_HELPERS = `(function(){
  if (window.__pupDeepInstalled) return; window.__pupDeepInstalled = true;
  // querySelectorAll piercing OPEN shadow roots
  window.$$deep = function(sel, root){ root = root||document; var out=[];
    var walk=function(n){ var f; try{f=n.querySelectorAll(sel);}catch(e){f=[];}
      for(var i=0;i<f.length;i++)out.push(f[i]);
      var a=n.querySelectorAll('*'); for(var j=0;j<a.length;j++){ if(a[j].shadowRoot) walk(a[j].shadowRoot); } };
    walk(root); return out; };
  window.$deep = function(sel, root){ var a=window.$$deep(sel,root); return a[0]||null; };
  // first VISIBLE element whose own text contains txt (case-insensitive), piercing shadow
  window.deepText = function(txt, root){ root=root||document; txt=(txt||'').toLowerCase(); var best=null;
    var walk=function(n){ var a=n.querySelectorAll('*'); for(var i=0;i<a.length;i++){ var el=a[i], own='';
      for(var k=0;k<el.childNodes.length;k++){ if(el.childNodes[k].nodeType===3) own+=el.childNodes[k].textContent; }
      if(own.toLowerCase().indexOf(txt)!==-1){ var r=el.getBoundingClientRect(); if(r.width>0&&r.height>0){ best=el; return true; } }
      if(el.shadowRoot&&walk(el.shadowRoot)) return true; } return false; };
    walk(root); return best; };
  // nearest scrollable ancestor of el (crosses shadow boundaries via host)
  window.__pupScrollableAncestor = function(el){ var n=el;
    while(n && n!==document.documentElement){ try{ var s=getComputedStyle(n), oy=s.overflowY;
      if((oy==='auto'||oy==='scroll'||oy==='overlay') && n.scrollHeight>n.clientHeight+1) return n; }catch(e){}
      n = n.parentElement || (n.getRootNode&&n.getRootNode().host) || null; }
    return document.scrollingElement||document.documentElement; };
  // the page's MAIN scroller: the document if it scrolls, else the biggest scrollable container
  window.__pupMainScroller = function(){ var doc=document.scrollingElement||document.documentElement;
    if(doc.scrollHeight>doc.clientHeight+1) return doc;
    var best=doc, area=0, all=window.$$deep('*');
    for(var i=0;i<all.length;i++){ var el=all[i]; try{ var s=getComputedStyle(el);
      if((s.overflowY==='auto'||s.overflowY==='scroll'||s.overflowY==='overlay') && el.scrollHeight>el.clientHeight+1){
        var a=(el.scrollHeight-el.clientHeight)*el.clientWidth; if(a>area){ area=a; best=el; } } }catch(e){} }
    return best; };
  window.__pupDesc = function(el){ if(!el) return null;
    var id=el.id?('#'+el.id):''; var cn=(el.className&&el.className.toString)?el.className.toString().trim():'';
    var cls=cn?('.'+cn.split(/\\s+/).slice(0,2).join('.')):''; return (el.tagName||'').toLowerCase()+id+cls; };
})();`;

async function ensureDeepHelpers(page) { try { await page.evaluate(PUP_DEEP_HELPERS); } catch (e) {} }

// Persistent Chrome profiles — each session gets its own userDataDir so IndexedDB, cookies,
// localStorage survive across restarts. v1.8.98: these live OUTSIDE the bridge dir, in the
// stable ~/.adom zone. __dirname is bridges-cache\puppeteer, which AD CLOBBERS on every
// bridge_install — storing profiles there wiped every persistent login (incl. the wiki-auth
// session) on every pup update. A stable home makes "log in once" actually last (across updates,
// restarts, reboots). One-time migration: pull an existing in-cache profiles/ dir over if present.
const ADOM_STATE_DIR = path.join(os.homedir(), '.adom');
const PROFILES_DIR = path.join(ADOM_STATE_DIR, 'pup-profiles');
const SESSIONS_DIR = path.join(ADOM_STATE_DIR, 'pup-sessions');
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
// One-time migration from the old clobber-prone in-bridge locations (best-effort).
try {
  for (const [oldRel, dest] of [['profiles', PROFILES_DIR], ['sessions', SESSIONS_DIR]]) {
    const oldDir = path.join(__dirname, oldRel);
    if (fs.existsSync(oldDir) && fs.readdirSync(dest).length === 0) {
      for (const entry of fs.readdirSync(oldDir)) {
        try { fs.renameSync(path.join(oldDir, entry), path.join(dest, entry)); } catch (e) {}
      }
      console.log(`[migrate] moved ${oldRel}/ from the bridge cache to ${dest}`);
    }
  }
} catch (e) {}

// Multi-session state: sessions are tabs within shared Chrome browsers
// One Chrome process per profile (userDataDir), multiple tabs per Chrome.
// browsers:  profileName → { browser, refCount }
// sessions:  sessionId → Session (see createSession below)
//
// Session shape (new — supports multiple tabs per session):
//   {
//     tabs:        [{ tabId, page, errors }]  // all open tabs for this session
//     activeTabId: string                      // currently "foregrounded" tab
//     latestShot:  string | null               // last screenshot path
//     profileName: string                      // Chrome profile
//     tabCounter:  number                      // monotonic per-session id counter
//     // Backward-compat virtual properties (getters):
//     page:   active tab's page   (for legacy callers that read session.page)
//     errors: active tab's errors
//   }
let browsers = new Map();
let sessions = new Map();
let activeSessionId = null;
let pupshotUrl = null;
// v1.9.4: profileName -> in-flight launch Promise. A fresh profile isn't in `browsers` until its
// Chrome launch FINISHES (~10-15s), so two near-simultaneous opens for the same profile used to
// each spawn their OWN detached Chrome on the SAME userDataDir; they then collided on Chrome's
// SingletonLock and mutually taskkilled each other's process, so NONE came up and the bridge
// looked wedged for minutes. This map lets concurrent callers JOIN the first launch instead of
// racing a second Chrome onto the same dir. (Root cause of the 2026-07-19 cold-start wedge.)
const _launchInFlight = new Map();

// ── Tab helpers ────────────────────────────────────────────────────
function createSession(profileName) {
  const session = {
    tabs: [],
    activeTabId: null,
    latestShot: null,
    profileName,
    tabCounter: 0,
    // v1.8.16: advisory ownership. Multiple AI threads share this bridge and
    // sessions are keyed only by sessionId — so one thread could silently
    // navigate away ("steal") a window another thread was using. `owner` is a
    // caller-declared label (browser_open_window {owner:"my-task"}); it can't
    // be authenticated, but it lets us REFUSE declared cross-owner takeovers
    // and reprimand anonymous ones with a loud hint.
    owner: null,
    createdAt: Date.now(),
    // v1.8.26: is this window CURRENTLY meant to be on the user's screen? false =
    // background (the hard default) — pup must never raise it (no bringToFront on
    // navigate/reload/switch_tab). Set true only when opened foreground:true or after
    // browser_raise_os_window, false again after browser_lower_os_window.
    _foreground: false,
    // Ring buffer of recently-closed tabs (capped at 20). Used by
    // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl`
    // when the bogus tabId matches a tab that closed in the last few seconds.
    // The common case: a popup PDF tab auto-closes after the viewer renders,
    // and the caller tries to eval/fetch against the stale tabId. We can now
    // surface the URL the popup was at so they can browser_fetch_url it
    // directly without having to re-trigger the click.
    recentlyClosedTabs: [],
  };
  // Legacy-compat getters: existing code that reads session.page / session.errors
  // transparently sees the active tab's page / errors.
  Object.defineProperty(session, 'page', {
    enumerable: false,
    get() { const t = getActiveTab(session); return t ? t.page : null; },
  });
  Object.defineProperty(session, 'errors', {
    enumerable: false,
    get() { const t = getActiveTab(session); return t ? t.errors : []; },
  });
  return session;
}

function nextTabId(session) {
  session.tabCounter = (session.tabCounter || 0) + 1;
  return `tab-${session.tabCounter}`;
}

function getActiveTab(session) {
  if (!session || !session.tabs || session.tabs.length === 0) return null;
  if (session.activeTabId) {
    const t = session.tabs.find(t => t.tabId === session.activeTabId);
    if (t) return t;
  }
  return session.tabs[0];
}

function getTabById(session, tabId) {
  if (!session || !tabId) return null;
  return session.tabs.find(t => t.tabId === tabId) || null;
}

// Attach page → tab to a session, wire error collection + console forwarding.
// Called by both launchSession (opening new window) and browser_open_tab.
//
// `opener` documents how the tab came to exist:
//   - "user" (default): Claude requested it via browser_open_window/browser_open_tab
//   - "popup":          Chrome opened it via window.open() / target=_blank — auto-tracked
//                       by the targetcreated listener wired in wirePopupTracking().
//                       openerTabId names the tab that triggered the popup.
// v1.9.48: debounced category re-check, fired by a tab's framenavigated. Debounced because a single
// user navigation emits several frame events (redirects, client-side routes) and each one would
// otherwise cost an AUMID re-register + re-stamp. Re-stamping is only done when the category ACTUALLY
// changed, so a settled window costs one cheap comparison.
const _catRecheckTimers = new Map();
function scheduleCategoryRecheck(sessionId) {
  if (process.platform !== 'win32') return;
  clearTimeout(_catRecheckTimers.get(sessionId));
  _catRecheckTimers.set(sessionId, setTimeout(async () => {
    _catRecheckTimers.delete(sessionId);
    try {
      const s = sessions.get(sessionId);
      if (!s) return;
      const cat = pupCategory(sessionId);
      if (!cat || cat === s._lastCat) return;
      console.log(`[identity] "${sessionId}" category changed ${s._lastCat || 'none'} -> ${cat} (after navigation) — re-stamping`);
      s._lastCat = cat;
      updateThumbnailTooltip(s, sessionId).catch(() => {});
      for (let i = 0; i < 4; i++) {
        let ok = false;
        try { ok = await brandPupWindow(sessionId); } catch (e) {}
        if (ok) { s._badgedAt = Date.now(); return; }
        await new Promise(r => setTimeout(r, 900 * (i + 1)));
      }
      // Do NOT leave _lastCat claiming success: the stamp never landed, so the cached value would
      // suppress every future attempt and freeze the wrong icon permanently.
      s._lastCat = undefined;
      console.log(`[identity] "${sessionId}" post-navigation re-stamp exhausted retries (wanted ${cat}) — category cache cleared so a later trigger retries`);
    } catch (e) {}
  }, 1500));
}

function attachTab(session, sessionId, page, { makeActive = true, opener = 'user', openerTabId = null } = {}) {
  const tabId = nextTabId(session);
  const errors = [];
  setupErrorCollection(page, errors);
  // v1.8.17: PERSISTENT title-suffix injector. AD's browser_raise/lower_os_window
  // (AD-core verbs, not this bridge — they shadow our fallback handlers) locate the
  // OS window by its " (session: <id>)" title suffix. Post-goto page.evaluate re-adds
  // the suffix, but a NEW page sets its own title BEFORE that runs → a brief window
  // where the suffix is absent and AD's lookup fails ("No visible window with title
  // containing …") right after a navigation. evaluateOnNewDocument installs the
  // enforcer at document-start on EVERY navigation (before page scripts), so the
  // suffix reappears the instant the page sets its title — closing that gap.
  try {
    page.evaluateOnNewDocument((sid) => {
      // TOLERANT marker check (v1.8.19): a tab's own suffix may be
      // " (session: <sid>)" OR " (session: <sid> | <tabId>)". This injector must
      // only ADD a marker when NONE is present — if it enforced an exact
      // " (session: <sid>)" it would fight addTabToSession's " | <tabId>" suffix:
      // two MutationObservers appending different strings ping-pong forever, peg the
      // renderer, and flood the CDP pipe (which HUNG browser_open_tab on Edge and
      // poisoned the whole bridge). Matching on the marker PREFIX makes it cooperate.
      const MARKER = ' (session: ' + sid;   // no closing paren — matches both forms
      const BASE = ' (session: ' + sid + ')';
      // v1.9.49: do NOT require a pre-existing title. This guard used to be
      //     if (document.title && ...)
      // so a page that never sets a <title> (plenty of app routes and SPA shells serve one) kept an
      // EMPTY title, the session marker was never injected, and the OS window title stayed a bare
      // "Google Chrome for Testing". AD resolves pup windows by their "(session: <id>)" suffix, so
      // that window became INVISIBLE to every branding call: identity stamp, category flip, overlay
      // and jump list all failed with "No visible window with title containing (session: ...)".
      // That is the failure behind icons that look stale or never update — the window was findable
      // right up until it navigated to a title-less page. An empty title must still get the marker.
      // v1.9.56: SELF-HEALING against DUPLICATES. The old check only asked "is my marker absent?",
      // so once a second copy appeared it was never collapsed — measured live as
      //   "○ Public · Hydrogen Desktop - Adom Wiki (session: hdr-wiki) (session: hdr-wiki)"
      // (107-char title). Two independent MutationObservers touch this title (this session-suffix
      // injector and the wiki state-glyph injector); a page that rewrites its own title can let both
      // fire before either sees the other's write, and each appends. Duplicated tags bloat the
      // taskbar-thumbnail CAPTION (the surface we want for status) and, worse, AD resolves pup windows
      // by "(session: <id>" — the same duplicate-tag class that previously made branding land on the
      // WRONG window. So: count OUR OWN tag and normalise to exactly one. Only our own sid is touched,
      // so an adopted tab's re-tag (retagSessionTitle) is never fought.
      const RE_MINE = new RegExp('\\s*\\(session: ' + sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[^)]*)\\)', 'g');
      const ensure = () => {
        try {
          const cur = String(document.title || '');
          const n = (cur.match(RE_MINE) || []).length;
          if (n === 1) return;                                   // already exactly one — leave it alone
          if (n === 0) { document.title = cur + BASE; return; }  // none — add ours
          document.title = cur.replace(RE_MINE, '') + BASE;      // duplicates — collapse to one
        } catch (e) {}
      };
      const observe = () => {
        const t = document.querySelector('title') || document.head || document.documentElement;
        if (!t) return false;
        try { new MutationObserver(ensure).observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}
        return true;
      };
      ensure();
      if (!observe()) document.addEventListener('DOMContentLoaded', () => { observe(); ensure(); });
    }, sessionId).catch(() => {});
  } catch (e) {}
  // v1.8.21: install the shadow-DOM + scroll helpers on every document so browser_eval
  // can use $deep / $$deep / deepText without hand-rolling a shadow-root walk.
  try { page.evaluateOnNewDocument(PUP_DEEP_HELPERS).catch(() => {}); } catch (e) {}
  // v1.9.48: re-evaluate the window CATEGORY when this tab finishes navigating.
  //
  // Category used to be recomputed ONLY inside updateTabCountBadge, i.e. only when a tab VERB ran.
  // A tab's URL is not known at that instant — the page is still about:blank while the navigation
  // is in flight — so the category was computed from the tabs that happened to have loaded, cached
  // in _lastCat, and then never revisited, because nothing re-checks after a page settles. Opening
  // two tabs back-to-back reliably froze a wiki+web+app window on the plain "wiki" icon instead of
  // "mixed" (caught by the taskbar audit as r1: 3 tabs, registered wiki).
  // The navigation event is the correct trigger: it is exactly when a tab's category becomes known.
  try {
    page.on('framenavigated', (frame) => {
      try { if (frame !== page.mainFrame()) return; } catch (e) { return; }
      scheduleCategoryRecheck(sessionId);
    });
  } catch (e) {}
  // v1.8.45: track when the USER activates this window (e.g. clicks its taskbar button to
  // watch it) so pup can report per-window flash state PROGRAMMATICALLY. The window 'focus'
  // event fires when the OS window is activated; we stamp the time. A focus AFTER the last
  // flash means the user cleared that flash. Timestamps use the desktop's own clock — the
  // same machine the bridge runs on — so they're directly comparable to _flashState.
  const PUP_FOCUS_HOOK = () => {
    try {
      if (window.__pupFocusHook) return; window.__pupFocusHook = true;
      if (typeof window.__pupFocusTs !== 'number') window.__pupFocusTs = 0;
      window.addEventListener('focus', () => { try { window.__pupFocusTs = Date.now(); } catch (e) {} }, true);
    } catch (e) {}
  };
  try {
    // Future documents (navigations) get it at document-start…
    page.evaluateOnNewDocument(PUP_FOCUS_HOOK).catch(() => {});
    // …AND the CURRENT document gets it right now. evaluateOnNewDocument alone only fires on
    // the NEXT navigation — a page that had already navigated when attachTab ran never got the
    // hook at all (v1.8.45-55 bug: every clicked window still reported flash 'pending', so the
    // "which windows has the user seen" query silently returned garbage).
    page.evaluate(PUP_FOCUS_HOOK).catch(() => {});
  } catch (e) {}
  // Tag console logs with tabId so the caller can tell which tab emitted them
  page.on('console', msg => {
    try {
      const kind = msg.type();
      const text = msg.text();
      console.log(`[pup console ${sessionId}/${tabId} ${kind}] ${text}`);
    } catch {}
  });
  // When the user closes a tab via Chrome UI, keep our state in sync
  page.on('close', () => {
    const idx = session.tabs.findIndex(t => t.tabId === tabId);
    if (idx !== -1) {
      // Capture the URL BEFORE the page handle is GC'd, so resolveTabOrError
      // can include it in tab_not_found errors. Critical for popup workflows
      // (PDF popups auto-close after the viewer renders; without lastKnownUrl
      // the caller has to re-trigger the click that spawned the popup).
      let lastKnownUrl = null;
      try { lastKnownUrl = page.url(); } catch {}
      const closedEntry = {
        tabId,
        url: lastKnownUrl,
        opener: opener || 'user',
        openerTabId: openerTabId || null,
        closedAt: Date.now(),
      };
      if (!session.recentlyClosedTabs) session.recentlyClosedTabs = [];
      session.recentlyClosedTabs.push(closedEntry);
      // Cap the ring buffer at 20 so a long-lived session with many popups
      // doesn't slowly leak memory through this list.
      if (session.recentlyClosedTabs.length > 20) {
        session.recentlyClosedTabs.shift();
      }
      session.tabs.splice(idx, 1);
      if (session.activeTabId === tabId) {
        session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;
      }
      console.log(`Tab "${tabId}" of session "${sessionId}" closed by user. Remaining tabs: ${session.tabs.length}. Last URL: ${lastKnownUrl || '(unknown)'}`);
    }
    // Auto-stop any tab recording for this tab
    try {
      if (typeof tabRecordings !== 'undefined') {
        for (const [recId, state] of [...tabRecordings.entries()]) {
          if (state.sessionId === sessionId && state.tabId === tabId && !state.stopping) {
            state.stopReason = 'tabClosed';
            tabRecordStopImpl({ sessionId, recordingId: recId }).catch(e =>
              console.error(`[${recId}] auto-stop on tab close failed: ${e.message}`));
          }
        }
      }
    } catch (e) { console.error(`tab-close recording cleanup failed: ${e.message}`); }
  });
  session.tabs.push({ tabId, page, errors, opener, openerTabId });
  if (makeActive) session.activeTabId = tabId;

  // v1.6.3+: enable scripted downloads on this page by default. Pup is a
  // tool-driving surface; chip-fetcher and similar tools need JS-triggered
  // downloads (a, fetch+blob, window.location=url) to actually save instead
  // of being silently dropped on the floor by Chrome. See
  // applyDownloadBehavior for rationale.
  //
  // Runs async (don't block attachTab on it) and idempotent — re-applying
  // is harmless. Re-fires on every same-tab main-frame navigation as a
  // safety belt: empirically modern Chrome doesn't reset the download
  // policy across same-context navs, but cross-context (target detach,
  // browser-restored tab) can lose it. Re-applying costs nothing.
  const downloadPath = (session._downloadPath) || path.join(os.homedir(), 'Downloads');
  applyDownloadBehavior(page, downloadPath).catch(() => {});
  page.on('framenavigated', frame => {
    try {
      if (frame === page.mainFrame()) {
        applyDownloadBehavior(page, downloadPath).catch(() => {});
      }
    } catch {}
  });

  // v1.8.80: any tab addition (incl. popup auto-attach) refreshes the tab-count badge
  setTimeout(() => { try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {} }, 500);
  return tabId;
}

// Find the (sessionId, tabId) tuple whose page matches a Puppeteer Target's page.
// Used by the popup tracker to figure out which existing tab triggered window.open().
async function findTabForTarget(target) {
  let openerPage = null;
  try { openerPage = await target.page(); } catch {}
  if (!openerPage) return null;
  for (const [sid, s] of sessions) {
    for (const t of s.tabs) {
      if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };
    }
  }
  return null;
}

// Wire popup auto-tracking on a freshly launched (or reconnected) browser.
// Whenever Chrome opens a new top-level page that we DIDN'T explicitly create
// (window.open(), target="_blank" form post, <a target="_blank"> click, etc.),
// `targetcreated` fires. We resolve the target's page, find the session that
// owns its opener (via target.opener()), and attach the popup to that session
// as a tracked tab. browser_list_tabs then includes it like any other tab —
// callers can switch to it, eval against it, screenshot it, close it.
//
// Edge cases:
//   - Target has no opener (top-level Chrome window opened by external means)
//     → drop; we don't know which session it belongs to.
//   - Target is already attached (we created it via launchSession or open_tab
//     and the targetcreated event raced our explicit attach) → drop.
//   - Browser is the desktop recorder window (separate profile) → drop.
function wirePopupTracking(browser, profileName) {
  browser.on('targetcreated', async (target) => {
    try {
      if (target.type() !== 'page') return;

      // Check opener() FIRST — it's a synchronous target-info property, no page init.
      // A popup we care about (window.open / target=_blank) HAS an opener; a target
      // with NO opener is either the tab WE just made via browser.newPage() in
      // addTabToSession, or a truly external window — both of which we drop.
      // CRITICAL (v1.8.18): we must NOT `await target.page()` on an openerless target.
      // On Edge, awaiting the page of a target that our own newPage() is still
      // initializing re-enters the single-threaded CDP pipe and HANGS newPage() —
      // which then poisons the whole connection (every later screenshot / list / nav
      // times out). Bailing on opener()===null before target.page() avoids it entirely.
      const openerTarget = target.opener();
      if (!openerTarget) {
        console.log(`[popup-track] new tab on profile "${profileName}" with no opener — not tracking (our own newPage or external)`);
        return;
      }

      const popupPage = await target.page();
      if (!popupPage) return;

      // Skip if some session already has this page registered (we attached it
      // explicitly; this is the targetcreated event firing for our own creation).
      for (const s of sessions.values()) {
        for (const t of s.tabs) {
          if (t.page === popupPage) return;
        }
      }
      const ownerInfo = await findTabForTarget(openerTarget);
      if (!ownerInfo) {
        console.log(`[popup-track] new tab on profile "${profileName}", opener target unknown to any session — not tracking`);
        return;
      }
      const session = sessions.get(ownerInfo.sessionId);
      if (!session) return;

      // Attach as a popup-origin tab. attachTab handles the standard error +
      // console + page.on('close') wiring same as user-opened tabs.
      const tabId = attachTab(session, ownerInfo.sessionId, popupPage, {
        makeActive: false, // don't yank focus; let caller decide via browser_switch_tab
        opener: 'popup',
        openerTabId: ownerInfo.tabId,
      });
      let popupUrl = '';
      try { popupUrl = popupPage.url(); } catch {}
      console.log(`[popup-track] new popup attached: session="${ownerInfo.sessionId}" tabId="${tabId}" openedBy="${ownerInfo.tabId}" url="${popupUrl}"`);
    } catch (e) {
      console.log(`[popup-track] error: ${e.message}`);
    }
  });
}

// Resolve which page+errors to operate on. If args.tabId is given, use that
// specific tab; else fall back to the session's active tab.
// Returns { tab, tabId } or throws a structured Error whose .message is JSON
// (so the dispatch layer can surface currentTabs + a hint without re-parsing).
//
// The hint matters most for popup-tab workflows: PDF popups and downloads
// auto-close fast (Chrome PDF viewer with Content-Disposition: attachment
// closes after the download). Callers who try to eval-against-the-popup
// after it's gone used to get the active tab's response silently
// (v1.4.8 and earlier had a quirk where some paths did this). We now error
// loudly with the exact recipe to use instead.
function resolveTabOrError(session, sessionId, args) {
  if (args.tabId) {
    const t = getTabById(session, args.tabId);
    if (!t) {
      const available = session.tabs.map(x => x.tabId);
      // v1.4.10: If the requested tabId is one we recently saw close (most
      // commonly a popup PDF that auto-closed after rendering), surface its
      // last known URL + closure metadata so the caller can immediately
      // browser_fetch_url it without having to re-trigger the spawning click.
      let recentlyClosed = null;
      if (Array.isArray(session.recentlyClosedTabs)) {
        const hit = session.recentlyClosedTabs.find(c => c.tabId === args.tabId);
        if (hit) {
          recentlyClosed = {
            lastKnownUrl: hit.url,
            opener: hit.opener,
            openerTabId: hit.openerTabId,
            closedMsAgo: Date.now() - hit.closedAt,
          };
        }
      }
      const payload = {
        error: `Tab "${args.tabId}" not found in session "${sessionId}".`,
        currentTabs: available,
        _hint:
          `Most likely the popup auto-closed (PDF popups close after the ` +
          `viewer renders if Content-Disposition is attachment, or if a ` +
          `download fired). The right pattern with popup tabs is: ` +
          `(1) browser_list_tabs IMMEDIATELY after the click that spawned ` +
          `the popup, (2) read the popup's url field from that response ` +
          `(don't eval against the popup's tabId), (3) browser_fetch_url ` +
          `with that URL — never eval-against-popup-then-fetch.`,
        errorCode: 'tab_not_found',
      };
      if (recentlyClosed) {
        Object.assign(payload, recentlyClosed);
        // Sharpen the hint when we have a URL: the caller can act on it now.
        if (recentlyClosed.lastKnownUrl) {
          payload._hint =
            `This tab closed ${Math.round(recentlyClosed.closedMsAgo / 1000)}s ago at URL ${recentlyClosed.lastKnownUrl}. ` +
            `Call browser_fetch_url with that URL (and the opener tabId "${recentlyClosed.openerTabId || 'tab-1'}" for cookies) to get its bytes — ` +
            `do NOT try to eval against the closed tabId.`;
        }
      }
      const err = new Error(JSON.stringify(payload));
      err._isStructured = true;
      throw err;
    }
    return { tab: t, tabId: t.tabId };
  }
  const t = getActiveTab(session);
  if (!t) {
    throw new Error(`Session "${sessionId}" has no open tabs.`);
  }
  return { tab: t, tabId: t.tabId };
}

// Send a structured-or-plain error from resolveTabOrError. If the error
// message is the structured JSON we throw above, parse it back out and
// surface the fields directly in the response. Otherwise plain string.
function sendTabResolveError(res, e) {
  if (e && e._isStructured) {
    try {
      const parsed = JSON.parse(e.message);
      sendJSON(res, { success: false, ...parsed });
      return;
    } catch {}
  }
  sendJSON(res, { success: false, error: e.message });
}

// Build a plain-object tab description for API responses.
async function describeTab(tab, activeTabId) {
  let url = null, title = null;
  try { url = tab.page.url(); } catch {}
  try { title = await tab.page.title(); } catch {}
  return {
    tabId: tab.tabId,
    url,
    title,
    active: tab.tabId === activeTabId,
    errorCount: tab.errors.length,
    // Provenance: "user" if Claude opened this tab via browser_open_window /
    // browser_open_tab, "popup" if the page itself spawned it via window.open()
    // / target=_blank. openerTabId names the tab that triggered the popup
    // (so callers can correlate "this datasheet popup came from product page X").
    opener: tab.opener || 'user',
    openerTabId: tab.openerTabId || null,
  };
}

// ── Persistent session file helpers ────────────────────────────────
function sessionFilePath(sessionId) {
  return path.join(SESSIONS_DIR, `${sessionId}.json`);
}

function saveSessionFile(sessionId, profileName, url) {
  const entry = browsers.get(profileName);
  let pid = null, cdpPort = null;
  if (entry) {
    // Prefer detached PID (Windows), fall back to Puppeteer's process()
    pid = entry.browser._detachedPid || null;
    try { if (!pid) pid = entry.browser.process()?.pid || null; } catch {}
    // Prefer stored CDP port, fall back to DevToolsActivePort file
    cdpPort = entry.browser._cdpPort || null;
    if (!cdpPort) {
      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');
      try {
        cdpPort = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;
      } catch {}
    }
  }
  const data = {
    sessionId, profile: profileName, url: url || null,
    pid, cdpPort,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
  try {
    fs.writeFileSync(sessionFilePath(sessionId), JSON.stringify(data, null, 2));
  } catch (e) {
    console.error(`Failed to save session file for "${sessionId}": ${e.message}`);
  }
}

function deleteSessionFile(sessionId) {
  try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {}
}

function readSessionFile(sessionId) {
  try {
    return JSON.parse(fs.readFileSync(sessionFilePath(sessionId), 'utf8'));
  } catch { return null; }
}

function listSessionFiles() {
  try {
    return fs.readdirSync(SESSIONS_DIR)
      .filter(f => f.endsWith('.json'))
      .map(f => {
        try { return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); } catch { return null; }
      })
      .filter(Boolean);
  } catch { return []; }
}

function isProcessAlive(pid) {
  if (!pid) return false;
  try {
    process.kill(pid, 0); // signal 0 = check existence, doesn't kill
    return true;
  } catch { return false; }
}

// ── Session-tag extraction (v1.5.1+) ──────────────────────────────────
// launchSession() injects ` (session: <sessionId> | profile: <profileName>)`
// or ` (session: <sessionId>)` into every page's title via a MutationObserver.
// On reconnect / rescan we recover the sessionId from this tag — it's our
// durable mapping from "puppeteer Page handle" → "bridge sessionId" that
// survives across CDP socket drops, bridge restarts, and Chrome stay-alive
// scenarios. Without this tag (e.g. about:blank pages), the page is
// considered an orphan and will be adopted under a generated sessionId
// if rescan is asked to.
const SESSION_TAG_RE = /\(session:\s*([^|)]+?)(?:\s*\|.*)?\)\s*$/;
function parseSessionIdFromTitle(title) {
  if (!title) return null;
  const m = String(title).match(SESSION_TAG_RE);
  return m ? m[1].trim() : null;
}

// ── Disconnect-handling shared between every browser.on('disconnected')
// site (v1.5.1+) ──────────────────────────────────────────────────────
//
// Old (≤v1.5.0) behavior: when a browser disconnected (CDP socket dropped,
// network blip, puppeteer-side hiccup), the bridge eagerly purged every
// session for that profile from `sessions` AND deleted the on-disk session
// file. The Chrome window was still alive on the user's desktop, fully
// usable, but every reference to it had been wiped — so subsequent
// browser_navigate/eval/screenshot calls hit "Session not found" and
// (worse) browser_navigate without a sessionId silently retargeted
// to whatever session was still in memory, hijacking unrelated windows.
// Confirmed twice in chip-fetcher's session log on 2026-05-04.
//
// New behavior: keep the session entries in memory + on disk so they
// remain "known but currently disconnected." The next browser_rescan
// (manual) or 30-second health check (auto) will try to reconnect via
// the persisted DevToolsActivePort + verify Chrome's PID is still alive,
// then walk pages and re-attach by parsing the (session: X) tag from
// each page's title.
function handleBrowserDisconnect(browser, profileName) {
  const entry = browsers.get(profileName);
  if (!entry || entry.browser !== browser) return;
  browsers.delete(profileName);
  let touched = 0;
  for (const s of sessions.values()) {
    if (s.profileName === profileName) {
      s._lostBrowser = true;
      s._disconnectedAt = Date.now();
      touched++;
    }
  }
  console.log(
    `[disconnect] profile "${profileName}" disconnected — ${touched} session(s) marked as awaiting reconnect. ` +
    `Run browser_rescan to recover; the 30s health check will also auto-attempt.`
  );
}

// v1.6.1+: scan the running process list for a Chrome process whose
// command line includes `--user-data-dir=...<profileName>`. Returns
// `[{pid, cdpPort}, ...]` for every match. Used as a third-tier fallback
// when neither the on-disk session file nor the profile's
// DevToolsActivePort file knows about an alive Chrome — which is the
// canonical sleep/wake symptom (DevToolsActivePort is rewritten
// transiently and can be missing after wake).
async function findChromesByProfile(profileName) {
  // Use Windows tasklist + wmic equivalent OR PowerShell on Windows;
  // ps + grep elsewhere. Both return PID + full command line so we can
  // parse out --remote-debugging-port=<NNNN>.
  const matches = [];
  try {
    if (process.platform === 'win32') {
      // PowerShell's CIM is the most reliable way to get full command
      // lines on Windows. Fall back to wmic if PowerShell isn't
      // available (very rare on modern Windows). We use a single
      // -Command invocation so child-process startup cost is paid once.
      const { spawnSync } = require('child_process');
      const result = spawnSync('powershell', [
        '-NoProfile',
        '-Command',
        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match '${profileName.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&')}' } | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`,
      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
      if (result.stdout) {
        let parsed;
        try {
          parsed = JSON.parse(result.stdout);
        } catch {
          parsed = null;
        }
        const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);
        for (const item of items) {
          const cmd = item.CommandLine || '';
          // Only consider the parent Chrome process — skip --type=renderer
          // / --type=gpu-process / --type=crashpad-handler etc. They share
          // the user-data-dir but aren't the listening parent.
          if (/--type=/.test(cmd)) continue;
          // Must look like a pup-managed Chrome — has --remote-debugging-port.
          const portMatch = cmd.match(/--remote-debugging-port=(\d+)/);
          if (!portMatch) continue;
          // Must reference our profile dir specifically (not just the name
          // appearing somewhere in args).
          const profileNeedle = path.join(PROFILES_DIR, profileName).toLowerCase();
          if (!cmd.toLowerCase().includes(profileNeedle.toLowerCase())) continue;
          matches.push({ pid: item.ProcessId, cdpPort: parseInt(portMatch[1], 10) });
        }
      }
    } else {
      // POSIX: ps + grep. Same logic, simpler shape.
      const { spawnSync } = require('child_process');
      const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 });
      if (result.stdout) {
        for (const line of result.stdout.split('\n')) {
          if (!line.includes('chrome')) continue;
          if (/--type=/.test(line)) continue;
          const portMatch = line.match(/--remote-debugging-port=(\d+)/);
          if (!portMatch) continue;
          const profileNeedle = path.join(PROFILES_DIR, profileName);
          if (!line.includes(profileNeedle)) continue;
          const pidMatch = line.trim().match(/^(\d+)/);
          if (!pidMatch) continue;
          matches.push({ pid: parseInt(pidMatch[1], 10), cdpPort: parseInt(portMatch[1], 10) });
        }
      }
    }
  } catch (e) {
    console.log(`[findChromesByProfile] scan failed for "${profileName}": ${e.message}`);
  }
  return matches;
}

// v1.6.1+: kill every Chrome process associated with a profile, then
// remove the lock files Chrome leaves behind. Used before relaunching
// when a previous Chrome died unexpectedly (sleep/wake / crash) and
// left the profile in a state where a fresh `puppeteer.launch()` would
// exit immediately with "Chrome process exited immediately."
//
// Lock files we clean (cross-platform — Chrome creates a different set
// per OS):
//   - lockfile          (Windows — Chrome's own profile lock)
//   - SingletonLock     (Linux/macOS — Chrome's instance singleton)
//   - SingletonSocket   (Linux/macOS — same)
//   - SingletonCookie   (Linux/macOS — same)
//   - DevToolsActivePort (Chrome rotates this — stale value points at a
//     dead Chrome; we delete to force a re-write on next launch)
async function cleanProfileLocks(profileName) {
  const profileDir = path.join(PROFILES_DIR, profileName);
  if (!fs.existsSync(profileDir)) {
    return { cleaned: [], note: 'profile directory does not exist' };
  }
  const lockNames = [
    'lockfile',
    'SingletonLock',
    'SingletonSocket',
    'SingletonCookie',
    'DevToolsActivePort',
  ];
  const cleaned = [];
  for (const name of lockNames) {
    const p = path.join(profileDir, name);
    try {
      // unlinkSync errors if missing — that's fine, ignore.
      fs.unlinkSync(p);
      cleaned.push(name);
    } catch {}
  }
  console.log(`[cleanProfileLocks] profile "${profileName}": cleaned ${JSON.stringify(cleaned)}`);
  return { cleaned };
}

// v1.6.1+: kill every Chrome process matching this profile. Called
// before clean+relaunch when a stale Chrome is holding the profile.
// Idempotent — does nothing if no chrome processes match.
async function killOrphanChromesForProfile(profileName) {
  const found = await findChromesByProfile(profileName);
  let killed = 0;
  for (const { pid } of found) {
    try {
      if (process.platform === 'win32') {
        const { spawnSync } = require('child_process');
        spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { timeout: 5_000, windowsHide: true });
      } else {
        process.kill(pid, 'SIGKILL');
      }
      killed++;
    } catch (e) {
      console.log(`[killOrphan] failed to kill PID ${pid}: ${e.message}`);
    }
  }
  if (killed > 0) {
    console.log(`[killOrphan] killed ${killed} Chrome process tree(s) for profile "${profileName}"`);
    // Give the OS a beat to release file handles before the caller
    // tries to delete profile locks / relaunch.
    await new Promise(r => setTimeout(r, 1500));
  }
  return { killed };
}

// Try to puppeteer.connect to an already-alive Chrome for `profileName`,
// using the on-disk session file's recorded cdpPort + DevToolsActivePort
// fallback. Returns the Browser on success, null on failure. Caller is
// responsible for setting up listeners (handleBrowserDisconnect, popup
// tracking, default permissions). Idempotent — if a live entry already
// exists in `browsers`, returns that one.
async function attemptReconnectProfile(profileName) {
  const existing = browsers.get(profileName);
  if (existing && existing.browser.isConnected()) {
    return existing.browser;
  }
  // Pick any session file for this profile to discover cdpPort / pid.
  const files = listSessionFiles().filter(sf => sf.profile === profileName);
  let port = null, pid = null;
  for (const sf of files) {
    if (sf.cdpPort) port = port || sf.cdpPort;
    if (sf.pid) pid = pid || sf.pid;
  }
  // Fall back to DevToolsActivePort file in the profile dir.
  if (!port) {
    try {
      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');
      port = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;
    } catch {}
  }
  // v1.6.1+: third-tier fallback — scan running Chrome processes for one
  // whose --user-data-dir matches this profile. This catches the canonical
  // sleep/wake symptom: Chrome is alive, has its CDP port open and serving
  // /json/version, but the bridge has no on-disk record of it (e.g. session
  // file was deleted by an old aggressive disconnect handler before v1.5.1,
  // OR the bridge restarted after launch). See findChromesByProfile.
  if (!port) {
    const found = await findChromesByProfile(profileName);
    if (found.length > 0) {
      port = found[0].cdpPort;
      pid = pid || found[0].pid;
      console.log(`[reconnect] discovered orphan chrome via cmdline scan: profile="${profileName}" pid=${pid} port=${port}`);
    }
  }
  if (!port) {
    console.log(`[reconnect] no cdpPort known for profile "${profileName}" — cannot reconnect`);
    return null;
  }
  // Quick liveness probe with a 3s timeout (sleep/wake can leave half-open
  // sockets that hang fetch indefinitely; we'd rather fail fast and let
  // the next probe try again).
  const ac = new AbortController();
  const tid = setTimeout(() => ac.abort(), 3000);
  let ok = false;
  try {
    const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });
    ok = !!(resp && resp.ok);
  } catch {} finally {
    clearTimeout(tid);
  }
  if (!ok) {
    console.log(`[reconnect] CDP port ${port} for profile "${profileName}" not responding`);
    return null;
  }
  try {
    const browser = await Promise.race([
      puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),
      new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),
    ]);
    if (pid) browser._detachedPid = pid;
    browser._cdpPort = port;
    browsers.set(profileName, { browser, refCount: 0 });
    wirePopupTracking(browser, profileName);
    browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));
    console.log(`[reconnect] reconnected to Chrome on port ${port} for profile "${profileName}"`);
    return browser;
  } catch (e) {
    console.log(`[reconnect] puppeteer.connect failed for profile "${profileName}" port ${port}: ${e.message}`);
    return null;
  }
}

// Walk every page in `browser` and rebuild `sessions` entries for any
// page whose title carries a `(session: X)` tag. Pages without a tag
// are left alone unless `adoptOrphans:true` is passed, in which case
// they're attached under a generated sessionId so the caller can drive
// them. Idempotent — pages already attached to a live session are left
// in place.
async function rescanProfile(browser, profileName, { adoptOrphans = false } = {}) {
  const pages = await browser.pages();
  // Build the "page → existing tab" map so we know what's already attached.
  const existingPageTab = new Map();
  for (const s of sessions.values()) {
    if (s.profileName !== profileName) continue;
    for (const t of s.tabs) existingPageTab.set(t.page, { sid: null, t });
  }
  for (const [sid, s] of sessions.entries()) {
    if (s.profileName !== profileName) continue;
    for (const t of s.tabs) {
      const cur = existingPageTab.get(t.page);
      if (cur) cur.sid = sid;
    }
  }
  let reattached = 0, adopted = 0, alreadyLive = 0;
  const seenSessions = new Set();
  for (const page of pages) {
    if (existingPageTab.has(page)) {
      alreadyLive++;
      const cur = existingPageTab.get(page);
      if (cur && cur.sid) seenSessions.add(cur.sid);
      continue;
    }
    let title = '';
    try { title = await page.title(); } catch { /* page closed during rescan */ continue; }
    let sessionId = parseSessionIdFromTitle(title);
    if (!sessionId) {
      // No tag — could be a freshly-opened tab the user navigated to via
      // Chrome's New Tab Page, or a page the bridge already managed but
      // whose title got replaced by a script before the MutationObserver
      // could re-inject. Adopt only when explicitly asked.
      if (!adoptOrphans) continue;
      sessionId = `orphan-${profileName}-${Date.now()}-${adopted}`;
      adopted++;
    }
    let session = sessions.get(sessionId);
    if (!session) {
      session = createSession(profileName);
      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution
      sessions.set(sessionId, session);
    } else if (session.profileName !== profileName) {
      // SessionId collision across profiles — shouldn't happen in practice
      // since profile defaults to sessionId, but guard anyway. Skip rather
      // than clobber.
      console.log(`[rescan] sessionId "${sessionId}" found in profile "${session.profileName}" but page is in profile "${profileName}" — skipping`);
      continue;
    }
    session._lostBrowser = false;
    delete session._disconnectedAt;
    attachTab(session, sessionId, page, { makeActive: false });
    seenSessions.add(sessionId);
    reattached++;
  }
  // Sessions for this profile that have no live tab any more (Chrome may
  // have closed the tab while disconnected) keep their entries marked
  // _lostBrowser so the strict resolveSession path surfaces them clearly.
  for (const [sid, s] of sessions.entries()) {
    if (s.profileName !== profileName) continue;
    if (!seenSessions.has(sid) && s.tabs.length === 0) {
      s._lostBrowser = true;
    }
  }
  console.log(
    `[rescan] profile "${profileName}": ${reattached} reattached, ${adopted} orphans adopted, ` +
    `${alreadyLive} pages already live`
  );
  // v1.8.57: after a reattach, self-heal a window stranded off-screen by an older build's
  // failed park (one heal per profile — all this profile's sessions share the window).
  if (reattached > 0) {
    for (const sid of seenSessions) {
      const s = sessions.get(sid);
      if (s && s.tabs.length > 0) { healOffscreenWindow(s, sid).catch(() => {}); break; }
    }
  }
  return { reattached, adopted, alreadyLive };
}

// Walk every known profile (in-memory + on-disk) and try to rebuild a
// complete session map. Used by browser_rescan and by the 30s health
// check when it detects a disconnected browser whose Chrome PID is
// still alive. Returns aggregated stats for the response payload.
//
// v1.6.1+: also discovers profiles via a running-process scan. Catches
// the case where the bridge restarted (or was launched fresh after a
// crash) and a Chrome window is still alive on the desktop with no
// in-memory or on-disk record. The chipsmith-after-sleep scenario hit
// exactly this: Chrome alive, bridge had nothing for it.
async function rescanAllProfiles({ adoptOrphans = false } = {}) {
  const profileNames = new Set();
  for (const p of browsers.keys()) profileNames.add(p);
  for (const sf of listSessionFiles()) profileNames.add(sf.profile);
  // v1.6.1: scan for orphan Chrome processes running with a profile dir
  // under our PROFILES_DIR — they're pup-managed Chromes that the bridge
  // has lost track of. Each match is a profile we should try to recover.
  try {
    if (process.platform === 'win32') {
      const { spawnSync } = require('child_process');
      const profilesDir = PROFILES_DIR.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&');
      const result = spawnSync('powershell', [
        '-NoProfile', '-Command',
        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match 'remote-debugging-port' -and $_.CommandLine -match '${profilesDir}' -and $_.CommandLine -notmatch '--type=' } | ForEach-Object { if ($_.CommandLine -match 'user-data-dir=([^"]*?)(?=\\s+--|"$|$)') { Split-Path -Leaf $matches[1] } } | Sort-Object -Unique`,
      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
      if (result.stdout) {
        for (const line of result.stdout.split('\n')) {
          const name = line.trim().replace(/['"]/g, '');
          if (name) profileNames.add(name);
        }
      }
    } else {
      // POSIX equivalent — quick ps + grep + extract.
      const { spawnSync } = require('child_process');
      const result = spawnSync('ps', ['-eo', 'command'], { encoding: 'utf8', timeout: 5_000 });
      if (result.stdout) {
        const seen = new Set();
        for (const line of result.stdout.split('\n')) {
          if (!line.includes('chrome')) continue;
          if (line.includes('--type=')) continue;
          if (!line.includes('remote-debugging-port')) continue;
          if (!line.includes(PROFILES_DIR)) continue;
          const m = line.match(new RegExp(`user-data-dir=([^\\s]+/${path.basename(PROFILES_DIR)}/[^\\s]+)`));
          if (m) {
            const name = path.basename(m[1]);
            if (!seen.has(name)) { seen.add(name); profileNames.add(name); }
          }
        }
      }
    }
  } catch (e) {
    console.log(`[rescanAll] orphan process scan failed: ${e.message}`);
  }
  let totalReattached = 0, totalAdopted = 0, profilesScanned = 0, profilesReconnected = 0, profilesUnreachable = 0;
  for (const profileName of profileNames) {
    let browser = browsers.get(profileName)?.browser;
    if (!browser || !browser.isConnected()) {
      browser = await attemptReconnectProfile(profileName);
      if (browser) profilesReconnected++;
    }
    if (!browser) {
      profilesUnreachable++;
      // Mark any in-memory sessions for this profile as still disconnected
      for (const s of sessions.values()) {
        if (s.profileName === profileName) s._lostBrowser = true;
      }
      continue;
    }
    try {
      const r = await rescanProfile(browser, profileName, { adoptOrphans });
      totalReattached += r.reattached;
      totalAdopted += r.adopted;
      profilesScanned++;
    } catch (e) {
      console.log(`[rescan] profile "${profileName}" rescan threw: ${e.message}`);
    }
  }
  return { totalReattached, totalAdopted, profilesScanned, profilesReconnected, profilesUnreachable };
}

// Recover sessions from disk on startup — reconnect to existing Chrome processes
// v1.9.45: crash sentinel. Written before the risky recovery ops for ONE session and removed
// after that session settles, so a leftover file at boot names exactly the session that killed us.
const RECOVERY_SENTINEL = path.join(SESSIONS_DIR, '.recovery-in-flight');
let _poisonSessionId = null;

async function recoverSessions() {
  const files = listSessionFiles();
  try {
    if (fs.existsSync(RECOVERY_SENTINEL)) {
      _poisonSessionId = fs.readFileSync(RECOVERY_SENTINEL, 'utf8').trim() || null;
      if (_poisonSessionId) console.error(`[recovery] last boot died while recovering "${_poisonSessionId}" — that ONE session will be quarantined`);
      fs.unlinkSync(RECOVERY_SENTINEL);
    }
  } catch (e) {}
  if (files.length === 0) return;
  console.log(`Recovering ${files.length} session(s) from disk...`);

  for (const sf of files) {
    const { sessionId, profile: profileName, pid, cdpPort, url } = sf;

    // Skip if already in memory
    if (sessions.has(sessionId)) continue;

    // v1.9.6: PERSISTENT CRASH CIRCUIT BREAKER. A NATIVE crash during recovery (inside
    // puppeteer.connect / a CDP call / a native module) kills the process HARD — before the
    // per-session try/catch below can delete the file — so the SAME poison session file
    // re-crashes pup on EVERY boot: the ~60s respawn loop John hit 2026-07-19 (AD's lifecycle log
    // showed spawn-with-no-reap every minute; in-process try/catch can't stop a native crash).
    // Fix: persist a recover-attempt marker BEFORE the risky ops. A file that already has an
    // INCOMPLETE recovery attempt (crashed us last boot) is QUARANTINED — deleted, not retried.
    // A recovery that FAILS gracefully deletes its own file (catch below), and a recovery that
    // SUCCEEDS clears the marker (saveSessionFile rewrites without it) — so a leftover marker
    // means "this file crashed recovery," and one strike quarantines it. Bounds any poison file
    // to a single crash instead of an unbounded loop.
    // v1.9.45: the per-session `recoverAttempts` strike above was WRONG and cost John all 6 of his
    // windows. It quarantined any file carrying an unfinished-attempt marker, but "unfinished" is
    // not the same as "crashed": any restart that races the marker-clearing write leaves the mark
    // behind on a perfectly healthy session, so a NORMAL restart could quarantine EVERYTHING (log:
    // "Recovering 6 session(s) from disk... Recovery complete. 0 session(s) active"). Crash
    // protection must not be able to delete healthy sessions — that is a worse failure than the
    // respawn loop it guards against.
    // The evidence we actually need is WHICH session was in flight when the process died. That is
    // one global sentinel, written before the risky ops and removed after, naming exactly one
    // session. A crash leaves it pointing at the poison file; everything else recovers untouched.
    if (_poisonSessionId === sessionId) {
      console.error(`Session "${sessionId}" was mid-recovery when pup died last boot (native crash) — QUARANTINING just this one; all other sessions still recover`);
      deleteSessionFile(sessionId);
      continue;
    }
    try { fs.writeFileSync(RECOVERY_SENTINEL, sessionId); } catch (e) {}

    // Check if Chrome is still alive
    if (!isProcessAlive(pid)) {
      console.log(`Session "${sessionId}" (PID ${pid}) is dead — removing stale file`);
      deleteSessionFile(sessionId);
      continue;
    }

    // Try to reconnect via CDP
    if (!cdpPort) {
      // Try reading DevToolsActivePort from profile dir
      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');
      try {
        const portData = fs.readFileSync(portFile, 'utf8').trim();
        sf.cdpPort = parseInt(portData.split('\n')[0], 10) || null;
      } catch {}
    }

    const port = sf.cdpPort || cdpPort;
    if (!port) {
      console.log(`Session "${sessionId}" has no CDP port — removing stale file`);
      deleteSessionFile(sessionId);
      continue;
    }

    try {
      // Check if Chrome is actually listening
      const resp = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);
      if (!resp || !resp.ok) {
        console.log(`Session "${sessionId}" CDP port ${port} not responding — killing orphaned Chrome PID ${pid}`);
        try { process.kill(pid, 'SIGKILL'); } catch (e) { console.log(`  kill failed: ${e.message}`); }
        deleteSessionFile(sessionId);
        continue;
      }

      // Reconnect browser if needed
      if (!browsers.has(profileName) || !browsers.get(profileName).browser.isConnected()) {
        const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });
        browser._detachedPid = pid; // Preserve PID so killBrowserProcess can find it
        browser._cdpPort = port;
        browsers.set(profileName, { browser, refCount: 0 });
        wirePopupTracking(browser, profileName);
        // v1.5.1+: shared disconnect handler — keeps sessions intact for
        // recovery instead of erasing them. See handleBrowserDisconnect.
        browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));
        console.log(`Reconnected to Chrome on port ${port} for profile "${profileName}"`);
      }

      // Find the page that matches this session's URL
      const browser = browsers.get(profileName).browser;
      const pages = await browser.pages();
      let page = null;

      // Try to match by URL
      if (url) {
        page = pages.find(p => {
          try { return p.url().startsWith(url.split('?')[0]); } catch { return false; }
        });
      }
      // Fallback: use the last page that isn't about:blank
      if (!page) {
        page = pages.filter(p => { try { return p.url() !== 'about:blank'; } catch { return false; } }).pop();
      }
      // Last resort: any page
      if (!page && pages.length > 0) {
        page = pages[pages.length - 1];
      }

      if (!page) {
        console.log(`Session "${sessionId}" — no pages found in Chrome, removing`);
        deleteSessionFile(sessionId);
        continue;
      }

      const session = createSession(profileName);
      attachTab(session, sessionId, page);
      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution
      sessions.set(sessionId, session);
      const be = browsers.get(profileName);
      if (be) be.refCount++;
      if (!activeSessionId) activeSessionId = sessionId;

      // v1.9.6: recovered CLEANLY — rewrite the file WITHOUT the recover-attempt marker so the
      // circuit breaker doesn't quarantine a healthy session on the next boot.
      try { let u = null; try { u = page.url(); } catch (e) {} saveSessionFile(sessionId, profileName, u); } catch (e) {}
      console.log(`Recovered session "${sessionId}" (profile: ${profileName}, URL: ${page.url()})`);
    } catch (e) {
      console.error(`Failed to recover session "${sessionId}": ${e.message}`);
      deleteSessionFile(sessionId);
    }
  }
  // Every session got through its risky section, so nothing is in flight — drop the sentinel.
  // (Each iteration rewrites it, so a crash always leaves it naming the session that died.)
  try { if (fs.existsSync(RECOVERY_SENTINEL)) fs.unlinkSync(RECOVERY_SENTINEL); } catch (e) {}
  _poisonSessionId = null;
  console.log(`Recovery complete. ${sessions.size} session(s) active.`);
}

// Catch unhandled errors so server never crashes
process.on('uncaughtException', (e) => {
  console.error('Uncaught:', e.message);
});
process.on('unhandledRejection', (e) => {
  console.error('Unhandled rejection:', e);
});
// On bridge shutdown, disconnect from Chrome without killing it.
// Chrome has handleSIGINT/SIGTERM/SIGHUP: false and --remote-debugging-port=0,
// so it'll keep running. The session files on disk let the next bridge reconnect.
process.on('exit', () => {
  for (const [, be] of browsers) {
    try { be.browser.disconnect(); } catch {}
  }
});
process.on('SIGINT', () => { process.exit(0); });
process.on('SIGTERM', () => { process.exit(0); });

function setupErrorCollection(pg, errorsArr) {
  pg.on('console', msg => {
    if (msg.type() === 'error') {
      errorsArr.push({ type: 'console.error', text: msg.text(), ts: Date.now() });
    }
  });
  pg.on('pageerror', err => {
    errorsArr.push({ type: 'exception', text: err.message, ts: Date.now() });
  });
  pg.on('requestfailed', req => {
    if (req.failure()) {
      errorsArr.push({ type: 'network', text: `${req.failure().errorText}: ${req.url()}`, ts: Date.now() });
    }
  });
}

// Kill a Chrome browser process forcefully by PID
// Falls back to browser.close() if PID isn't available.
// `browser` may be null when we're just cleaning up an orphaned lock file
// and have no live puppeteer handle.
async function killBrowserProcess(browser, profileName) {
  if (!profileName) {
    console.log('killBrowserProcess called without profileName — skipping');
    return;
  }
  let pid = null;
  if (browser) {
    pid = browser._detachedPid || null;
    if (!pid) {
      try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ }
    }
    // Try graceful close first
    try { await browser.close(); } catch (e) {
      console.log(`Graceful close failed for profile "${profileName}": ${e.message}`);
    }
  }

  // If no browser handle, try to recover PID from session files on disk
  if (!pid) {
    try {
      const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);
      if (sFiles.length > 0) pid = sFiles[0].pid;
    } catch {}
  }

  // Then force-kill the PID if it's still around
  if (pid) {
    try {
      if (process.platform === 'win32') {
        execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
      } else {
        process.kill(pid, 'SIGKILL');
      }
      console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);
    } catch (e) {
      // PID already gone — that's fine
    }
  }

  // Also check if Chrome is still locking the profile dir
  const lockFile = path.join(PROFILES_DIR, profileName, 'SingletonLock');
  try { fs.unlinkSync(lockFile); } catch { /* doesn't exist or can't remove */ }
}

// ─── Profile session-restore prevention ──────────────────────────────────
//
// Persistent profiles (--user-data-dir) make Chrome remember the previous
// session. Without intervention, Chrome's "restore previous session" path
// re-opens every tab the user had on last close, AND if Chrome thinks the
// last exit was unclean (which happens whenever puppeteer's child process
// is force-killed) it pops the "Restore tabs?" bubble too. End result is
// stale tab buildup across close/reopen cycles — 8+ tabs after a few iters.
//
// The reliable fix is two-pronged at profile level:
//
// 1. Patch <userDataDir>/Default/Preferences before launch:
//    - profile.exit_type = "Normal"
//    - profile.exited_cleanly = true
//      → Chrome thinks the last exit was clean, skips the restore bubble.
//    - session.restore_on_startup = 5 (open new tab page)
//    - session.startup_urls = []
//      → Even if user had set "On startup → continue where you left off",
//        we override it to "Open the New Tab page".
//
// 2. Delete the session-state blobs that Chrome reads to rebuild tabs:
//    - Default/Last Session, Default/Last Tabs
//    - Default/Current Session, Default/Current Tabs
//    - Default/Sessions/    (newer Chromium session-store dir)
//    - Default/Tabs/         (newer Chromium tab-store dir)
//
// We only run this when LAUNCHING (not when reconnecting to a running
// Chrome via DevToolsActivePort). Reconnect path means Chrome is already
// alive and we shouldn't touch its profile files.
function prepareCleanProfile(userDataDir) {
  if (!userDataDir) return;
  try {
    const defaultDir = path.join(userDataDir, 'Default');
    fs.mkdirSync(defaultDir, { recursive: true });

    // 1. Patch Preferences JSON
    const prefsPath = path.join(defaultDir, 'Preferences');
    let prefs = {};
    try {
      const raw = fs.readFileSync(prefsPath, 'utf8');
      prefs = JSON.parse(raw);
    } catch (e) {
      // Missing or unreadable Preferences file = first launch; that's fine
      prefs = {};
    }
    prefs.profile = prefs.profile || {};
    prefs.profile.exit_type = 'Normal';
    prefs.profile.exited_cleanly = true;
    prefs.session = prefs.session || {};
    prefs.session.restore_on_startup = 5; // 5 = new tab page; 1 = restore last
    prefs.session.startup_urls = [];
    fs.writeFileSync(prefsPath, JSON.stringify(prefs));

    // 2. Delete session-state blobs (best effort — Chrome may not have written some)
    const blobs = ['Last Session', 'Last Tabs', 'Current Session', 'Current Tabs'];
    for (const name of blobs) {
      const p = path.join(defaultDir, name);
      try { fs.rmSync(p, { force: true }); } catch {}
    }
    const dirs = ['Sessions', 'Tabs'];
    for (const name of dirs) {
      const p = path.join(defaultDir, name);
      try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
    }
  } catch (e) {
    // Profile cleanup is defense-in-depth — don't fail the launch if it errors
    console.log(`prepareCleanProfile("${userDataDir}") warning: ${e.message}`);
  }
}

// After launch, sweep any pages Chrome opened that weren't created by us.
// This is the second line of defense in case Preferences-patching missed
// some restore path. session.tabs[] tracks the pages WE registered; any
// browser.pages() entries beyond that are restored leftovers — close them.
// Keeps the page we just opened (matched by URL or by being the only one
// the bridge has registered).
async function closeRestoredLeftoverTabs(browser, keepPage) {
  let closed = 0;
  try {
    const pages = await browser.pages();
    for (const p of pages) {
      if (p === keepPage) continue;
      // Skip blank/about:blank (Chrome's default first tab) — handled separately
      let url = '';
      try { url = p.url() || ''; } catch {}
      if (url.startsWith('chrome://newtab') || url === 'about:blank' || url === '') {
        // Don't kill the blank tab here; new-tab reuse logic handles it
        continue;
      }
      try { await p.close({ runBeforeUnload: false }); closed++; } catch {}
    }
  } catch (e) {
    console.log(`closeRestoredLeftoverTabs warning: ${e.message}`);
  }
  return closed;
}

// Get or launch a Chrome browser for a given profile name
// Multiple sessions can share the same browser (as tabs) if they use the same profile
//
// v1.6.3+: full-capability default. Pup is a tool-driving surface, NOT a
// user-facing browser — every chip-fetcher-style flow needs Chrome in
// "everything allowed" mode so JS-triggered downloads don't get silently
// blocked, clipboard access works for pasting into vendor forms, and
// permission prompts don't pop up under automation.
//
// Two CDP mechanisms together give us this:
//
//   1. Browser.setPermission (per-origin permissions) — FULL DEBUG: every
//      permission Chrome accepts is GRANTED (notifications, geolocation,
//      camera, mic, midi, sensors, clipboard, wake-lock, …) so no permission
//      prompt EVER blocks a debug flow. Per-permission so an unknown/rejected
//      descriptor (e.g. clipboard-sanitized-write on some builds) only drops
//      itself, never the rest (the v1.4.12 Browser.grantPermissions array form
//      failed wholesale on Chrome 146 when one name was unrecognized).
//      Opt-out for the rare untrusted-site case: strictPermissions:true.
//
//   2. Page.setDownloadBehavior — see applyDownloadBehavior below. This
//      is the actual mechanism for unblocking scripted downloads. The
//      v1.4.12 attempt at "automaticDownloads" via grantPermissions
//      didn't help with scripted downloads — it controlled the multi-
//      file infobar, which is a different layer. setDownloadBehavior
//      tells Chrome to actually save the file when JS triggers a nav
//      to a binary content-type.
//
// Opt-out: pass { strictPermissions: true } to browser_open_window for
// sessions that genuinely need the user to gate every download (rare).
//
// History: v1.4.12 used Browser.grantPermissions(['automaticDownloads',
// 'notifications', 'clipboardReadWrite']). On Chrome 146 it failed with
// "Unknown permission type: automaticDownloads" — taking the whole
// grant down. v1.6.3 fixes by switching to Browser.setPermission per-
// permission and dropping the unhelpful automaticDownloads (which we
// replace with the actually-effective Page.setDownloadBehavior).
// FULL-DEBUG default: grant EVERYTHING Chrome will accept (John: always launch
// pup with full permissions for full debug). All 'granted', none denied. Applied
// per-permission below so any descriptor a given Chrome build rejects (e.g.
// clipboard-sanitized-write) only drops itself — the rest still land. Opt-out via
// strictPermissions:true on browser_open_window for an untrusted site you're auditing.
const PERMISSIVE_DEFAULT_PERMISSIONS = [
  // v1.8.69: window-management → getScreenDetails() works (multi-monitor enumeration from
  // inside the page; without the grant it hangs on a CDP-suppressed permission prompt).
  { name: 'window-management', setting: 'granted' },
  { name: 'clipboard-read', setting: 'granted' },
  { name: 'clipboard-write', setting: 'granted' },
  { name: 'clipboard-sanitized-write', setting: 'granted' },
  { name: 'notifications', setting: 'granted' },
  { name: 'geolocation', setting: 'granted' },
  { name: 'camera', setting: 'granted' },
  { name: 'microphone', setting: 'granted' },
  { name: 'midi', setting: 'granted' },
  { name: 'midiSysex', setting: 'granted' },
  { name: 'background-sync', setting: 'granted' },
  { name: 'background-fetch', setting: 'granted' },
  { name: 'periodic-background-sync', setting: 'granted' },
  { name: 'payment-handler', setting: 'granted' },
  { name: 'persistent-storage', setting: 'granted' },
  { name: 'durable-storage', setting: 'granted' },
  { name: 'storage-access', setting: 'granted' },
  { name: 'top-level-storage-access', setting: 'granted' },
  { name: 'idle-detection', setting: 'granted' },
  { name: 'display-capture', setting: 'granted' },
  { name: 'window-management', setting: 'granted' },
  { name: 'local-fonts', setting: 'granted' },
  { name: 'nfc', setting: 'granted' },
  { name: 'screen-wake-lock', setting: 'granted' },
  { name: 'system-wake-lock', setting: 'granted' },
  { name: 'accelerometer', setting: 'granted' },
  { name: 'gyroscope', setting: 'granted' },
  { name: 'magnetometer', setting: 'granted' },
  { name: 'ambient-light-sensor', setting: 'granted' },
  { name: 'sensors', setting: 'granted' },
  { name: 'protected-media-identifier', setting: 'granted' },
  { name: 'captured-surface-control', setting: 'granted' },
  { name: 'speaker-selection', setting: 'granted' },
];
async function applyDefaultPermissions(browser, profileName, { strictPermissions = false } = {}) {
  if (strictPermissions) {
    console.log(`[permissions] strictPermissions=true — leaving Chromium defaults in place for profile "${profileName}"`);
    return { applied: false, reason: 'strictPermissions' };
  }
  const session = await browser.target().createCDPSession().catch(() => null);
  if (!session) {
    console.log(`[permissions] could not open CDP session for "${profileName}" — skipping`);
    return { applied: false, reason: 'cdp-session-open-failed' };
  }
  const applied = [];
  const failed = [];
  try {
    for (const p of PERMISSIVE_DEFAULT_PERMISSIONS) {
      try {
        await session.send('Browser.setPermission', {
          permission: { name: p.name },
          setting: p.setting,
          // origin omitted → all origins on default browser context
        });
        applied.push(`${p.name}=${p.setting}`);
      } catch (e) {
        failed.push({ name: p.name, error: e.message });
      }
    }
  } finally {
    try { await session.detach(); } catch {}
  }
  if (applied.length > 0) {
    console.log(`[permissions] profile "${profileName}": applied [${applied.join(', ')}]${failed.length > 0 ? `; failed [${failed.map(f => f.name).join(', ')}]` : ''}`);
  } else if (failed.length > 0) {
    console.log(`[permissions] profile "${profileName}": ALL grants failed: ${JSON.stringify(failed)}`);
  }
  return { applied, failed };
}

// v1.6.3+: enable scripted downloads on a page. This is the SECOND half
// of pup's full-capability default (the first half is applyDefaultPermissions
// above). Without setDownloadBehavior=allow, JS-triggered downloads (a
// click on `<a download href="...">`, a `fetch().then(blob → save)`, a
// vendor "Download Symbol" button that does `window.location = url`) get
// SILENTLY BLOCKED by Chrome — no error, no UI, no file in ~/Downloads.
// chip-fetcher debugged this for hours before figuring out the cause.
//
// Why per-page and not per-browser: setDownloadBehavior is a Page-domain
// command. Each tab needs its own application. We also re-apply on every
// main-frame navigation as a safety belt — empirically Chrome doesn't
// reset across same-context navs, but cross-context (browser-restored
// tabs, target-domain disconnect/reconnect) can lose it.
//
// downloadPath defaults to %USERPROFILE%\Downloads on Windows / ~/Downloads
// on POSIX — chip-fetcher's `desktop_watch_files` polls this exact dir,
// so the two ends meet by default. Caller can override via the
// `downloadPath` arg on browser_open_window if they need somewhere else.
async function applyDownloadBehavior(page, downloadPath) {
  if (!downloadPath) {
    downloadPath = path.join(os.homedir(), 'Downloads');
  }
  // Ensure the dir exists — Chrome silently no-ops if the path doesn't
  // exist on Windows; the file ends up nowhere and the AI debug-loops.
  try { fs.mkdirSync(downloadPath, { recursive: true }); } catch {}
  let cdp;
  try {
    cdp = await page.target().createCDPSession();
    await cdp.send('Page.setDownloadBehavior', {
      behavior: 'allow',
      downloadPath,
    });
  } catch (e) {
    // Older Chromes used the now-deprecated Browser.setDownloadBehavior
    // (with browserContextId). Try that as a fallback. Modern Chrome
    // (≥83) supports both forms; this branch only fires on truly old
    // Chrome for Testing builds.
    try {
      if (cdp) await cdp.send('Browser.setDownloadBehavior', {
        behavior: 'allow',
        downloadPath,
      });
    } catch (e2) {
      console.log(`[downloads] setDownloadBehavior failed for page (non-fatal): ${e.message} / fallback: ${e2.message}`);
    }
  } finally {
    try { if (cdp) await cdp.detach(); } catch {}
  }
}

// The browser that most recently launched (kind/source/executablePath + any fallback
// chain), so browser_open_window's response + hints report what pup ACTUALLY drove —
// which can differ from resolveBrowser()'s top pick if the top candidate had to fall
// through. Read right after getOrLaunchBrowser() returns.
let _lastChosenBrowser = null;

// Build the verbose browser report the AI gets on every open: what we drove, what
// else is available, the current default, and how to switch or install full CfT.
function browserPickReport() {
  const sum = chrome.pickSummary();
  const chosen = _lastChosenBrowser || sum.picked;
  const availTxt = (sum.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';
  const def = sum.defaultBrowser;
  const defTxt = def
    ? `Default is ${def.kind}${def.forced ? ' (pinned via browser_use)' : ' (auto — cached from a prior successful launch)'}${def.pendingInstall ? ', still downloading' : ''}.`
    : 'Default is auto (pup drives the first browser that launches, then caches it).';
  const fellBack = chosen && chosen.fallbackFrom && chosen.fallbackFrom.length
    ? ` NOTE: fell back past ${chosen.fallbackFrom.join(', ')} which would not spawn.` : '';
  const hint = chosen
    ? `Driving ${chosen.kind}${chosen.source ? ` (${chosen.source})` : ''}${chosen.executablePath ? ` — ${chosen.executablePath}` : ''}, in a FRESH isolated profile (the user's real tabs/logins are untouched).${fellBack} Available on this machine: ${availTxt}. ${defTxt} To switch the browser pup drives: browser_use {browser:"chrome"|"edge"|"cft"|"auto"}. To install & pin full Chrome for Testing: browser_use {browser:"cft"} (fetches ~150 MB once, then every open uses it) or browser_prewarm.`
    : `No browser resolved. Available on this machine: ${availTxt}.`;
  return {
    browser: chosen ? { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null } : null,
    availableBrowsers: sum.candidates,
    defaultBrowser: def,
    _hint: hint,
  };
}

// Push a pup window to the BACK of the z-order WITHOUT activating it. Occluded, NOT
// minimized — it keeps compositing, so screenshots/eval still work while it sits behind
// the user's windows. This is what makes "open in pup" non-disruptive BY DEFAULT: the AI
// drives it invisibly and only an explicit request ever brings it forward. Scoped to THIS
// session's Chrome pid (+ its child chrome/msedge processes) so it never touches the
// user's real browser windows. Windows-only; no-op elsewhere.
function osSendWindowToBack(profileName) {
  if (process.platform !== 'win32') return false;
  const be = browsers.get(profileName);
  const pid = (be && be.browser && (be.browser._detachedPid || (be.browser.process() && be.browser.process().pid))) || null;
  if (!pid) return false;
  try {
    const psScript = path.join(require('os').tmpdir(), `_adom_bg_${pid}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices;',
      'public class WinBg { [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f); }',
      '"@',
      `$targetPid = ${pid}`,
      '$BOTTOM = [IntPtr]1',                 // HWND_BOTTOM
      '$F = 0x1 -bor 0x2 -bor 0x10',         // SWP_NOSIZE|SWP_NOMOVE|SWP_NOACTIVATE
      'function Back($h){ if($h -ne [IntPtr]::Zero){ [WinBg]::SetWindowPos($h,$BOTTOM,0,0,0,0,$F) | Out-Null } }',
      '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',
      'if ($p -and $p.MainWindowHandle -ne 0) { Back $p.MainWindowHandle }',
      'Get-CimInstance Win32_Process -Filter "ParentProcessId=$targetPid" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue; if ($cp -and $cp.MainWindowHandle -ne 0) { Back $cp.MainWindowHandle } }',
      'Write-Output "bg"',
    ].join('\r\n');
    fs.writeFileSync(psScript, ps);
    runPsHidden(psScript);
    return true;
  } catch (e) { console.error(`[bg] send-to-back failed for "${profileName}": ${e.message}`); return false; }
}

// v1.8.34: capture the user's CURRENT foreground window (decimal HWND string) right
// before we launch a pup browser, so the backgrounder can hand keyboard focus back to it.
function osGetForegroundWindow() {
  if (process.platform !== 'win32') return null;
  try {
    const scriptPath = path.join(require('os').tmpdir(), `_adom_fg_${process.pid}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices;',
      'public class WinFgG { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }',
      '"@',
      'Write-Output ([int64][WinFgG]::GetForegroundWindow())',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = runPsHidden(scriptPath, { timeout: 4000 }).trim();
    return out && out !== '0' ? out : null;
  } catch (e) { return null; }
}

// v1.8.40: badge each pup window's TASKBAR BUTTON with the white Adom leaves, so users can
// tell a pup-driven window from their own browser. Done via AD-core desktop_taskbar (custom
// PNG overlay, AD ≥1.9.106) — AD paints it cross-process natively; pup just hands over the
// art (an in-bridge SetOverlayIcon is in-process-only and can't badge the browser's window).
// We resolve the window by its "(session: <id>)" title suffix so AD finds the hwnd itself.
let _pupBadgeB64 = null;
function pupBadgeB64() {
  if (_pupBadgeB64 !== null) return _pupBadgeB64;
  try { _pupBadgeB64 = fs.readFileSync(path.join(__dirname, 'icons', 'pup-overlay-32.png')).toString('base64'); }
  catch (e) { _pupBadgeB64 = ''; }
  return _pupBadgeB64;
}
// v1.8.73: FULL WINDOW-IDENTITY TAKEOVER (AD >=1.9.134, built to pup's spec). Two halves:
// registration (once per bridge run: HKCU AUMID + Start Menu shortcut whose pin launches a REAL
// pup window via adom-desktop-cli) and per-window stamping (AUMID + WM_SETICON, done by AD-core
// because icon HICONs are process-owned USER objects that must outlive the caller — AD holds
// them in a process-lifetime cache; see dev skill "Full icon/identity takeover"). The bridge
// registers ITSELF over the loopback direct-API (trusted transport, Tier-2 verb is ungated
// there) and owns its icon in its install dir — change branding = ship a new pup release with a
// NEW icon FILENAME (Windows caches decoded icons by path). Self-gating: on AD <1.9.134 the
// verbs are unknown -> _identitySupported=false -> the overlay badge fallback keeps working.
// v1.8.78: AD-version awareness. Most capability gaps self-gate silently (identity -> badge,
// bottom/force -> PS fallback) and need no nagging. ONE gap is a POLICY hole, not cosmetics:
// AD <1.9.124 intercepts browser_raise_os_window natively, so pup's foreground gate (reason
// required + audit trail) silently does not exist there. pup can't enforce through an
// interceptor, but it can KNOW and SAY SO — probed once at startup, surfaced in /status and
// open-response hints so users/AIs are told to update AD rather than trusting a gate that
// isn't there.
let _adVersion = null;
let _adRaiseGateBypassed = false;
function _verLt(a, b) {
  const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);
  for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return true; if ((pa[i] || 0) > (pb[i] || 0)) return false; }
  return false;
}
async function probeAdVersion() {
  try {
    const r = await chrome.adCommand('update_status', {}, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' && r.output ? JSON.parse(r.output) : r.output || r);
    const v = (raw && (raw.currentVersion || (raw.data && raw.data.currentVersion))) || (r && r.currentVersion) || null;
    if (v) {
      _adVersion = v;
      _adRaiseGateBypassed = _verLt(v, '1.9.124');
      console.log(`[adver] AD ${v}${_adRaiseGateBypassed ? ' — WARNING: <1.9.124 intercepts raise, the foreground gate is BYPASSED on this box; recommend updating Adom Desktop' : ''}`);
    }
  } catch (e) {}
}
function adUpgradeHint() {
  return _adRaiseGateBypassed
    ? ` ⚠ AD ${_adVersion} on this desktop is older than 1.9.124: it intercepts browser_raise_os_window natively, so pup's foreground gate (foregroundReason + audit trail) does NOT apply to raises here. Recommend the user update Adom Desktop (update_check / the footer banner) — everything else degrades gracefully, this is the one policy gap.`
    : '';
}
// v1.8.82: PUP SETTINGS — user-configurable behavior, persisted OUTSIDE the bridge dir (the
// bridges-cache is clobbered on every update) in ~/.adom/pup-settings.json.
// taskbarGrouping (John's discovery while debugging the AUMID grouping):
//   'split'   (default) — per-session AUMID: ONE TASKBAR BUTTON PER WINDOW, each wearing its
//              own tab-count badge. Best for telling AI threads apart at a glance.
//   'grouped' — shared AUMID: ALL pup windows stack under ONE "Adom Pup" button (tidy taskbar);
//              the badge shows the WINDOW count (>=2) since per-window badges are meaningless
//              on a grouped button.
const PUP_SETTINGS_PATH = path.join(os.homedir(), '.adom', 'pup-settings.json');
let _pupSettings = null;
function pupSettings() {
  if (_pupSettings) return _pupSettings;
  try { _pupSettings = JSON.parse(fs.readFileSync(PUP_SETTINGS_PATH, 'utf8')); } catch (e) { _pupSettings = {}; }
  if (!_pupSettings.taskbarGrouping) _pupSettings.taskbarGrouping = 'split';
  return _pupSettings;
}
function savePupSettings(patch) {
  const cur = { ...pupSettings(), ...patch };
  try { fs.mkdirSync(path.dirname(PUP_SETTINGS_PATH), { recursive: true }); fs.writeFileSync(PUP_SETTINGS_PATH, JSON.stringify(cur, null, 2)); } catch (e) {}
  _pupSettings = cur;
  return cur;
}

const PUP_APP_ID = 'Adom.Pup';
const PUP_DISPLAY_NAME = 'Adom Pup';
let _identitySupported = null;   // null = unprobed
let _identityRegistered = false;
let _jumplistSupported = null;   // null = unprobed (desktop_set_window_jumplist, AD ≥1.9.139)
// v1.8.74: adom-pup-BROWSER.ico — favicon + browser-window inlay (lower-right), so the icon
// says "this is an Adom BROWSER window" at a glance. NEW FILENAME on art change, always —
// Windows' shell + AD's HICON cache key by path (stale-tile risk otherwise). Future home for
// the master art: the Adom brand-icons wiki repo; for now the pup repo owns it.
// v1.9.12: the taskbar icon now CARRIES THE ADOM-WIKI LOGIN STATE (John: "show an indicator in the
// pup icon whether the adom wiki is logged in... maybe that browser window icon gets filled in
// solid"). Two variants of the SAME art, differing only in the lower-right corner glyph:
//   adom-pup-browser3.ico    — browser-window OUTLINE  = logged OUT / not a wiki window (default)
//   adom-pup-wiki-authed1.ico — SOLID PADLOCK          = signed in to the Adom wiki
// Solid-vs-outline is the most legible difference at 16-32px (a mass/contrast change survives
// downscaling; fine shape detail does not), and the padlock adds the semantic meaning on top.
// The OVERLAY slot is NOT used for this — it belongs to the active tab's favicon (v1.9.0).
// NEW FILENAME on any art change, always: the shell + AD cache HICONs by PATH (stale-tile trap).
// v1.9.19: CATEGORY ICONS. A pup window's taskbar icon says WHAT KIND of thing it is showing.
// Every icon is the teal Adom tile (the pup identity, the container) carrying ONE monochrome
// #e6edf3 glyph drawn on the 24x24 house grid, per the brand icon law: custom hand-drawn, single
// colour, no gradients, no shadows, NO EMOJI. Glyphs match the shipped family style (see the brand
// page icons: symbol, step, shotlog).
//   wiki     open book, outline   Adom wiki, public / logged out
//   wiki-in  open book, SOLID     Adom wiki, signed in   (solid vs hollow is the one mass change
//                                 that survives Windows painting the button at ~24px; a padlock
//                                 turned to mush at that size)
//   app      window + prompt      an Adom app: localhost or a cloud slug proxy URL
//   web      globe                anything else on the internet
//   mixed    stacked windows      tabs spanning more than one category
// v1.9.54: the RETIRED default was adom-pup-browser3.ico (the old "Adom mark fills the tile + tiny
// corner window" brand). John retired it in favour of the teal-tile category family. The new default
// (pup-cat-default9.ico) matches that family — teal tile + dark-teal window body + a neutral
// browser-chrome glyph — so a category-less window and the base Adom Pup identity no longer wear the
// old art anywhere. The 5 categories still override it via PUP_CAT_ICONS.
const PUP_ICON_DEFAULT = 'pup-cat-default9.ico';
// v1.9.45 — ICON GENERATION. BUMP THIS whenever the icon FILES or the AUMID branding mechanism
// change, and every live window gets a freshly-painted taskbar button.
//
// WHY it must exist: Win11 bakes a taskbar button's tile when the button is CREATED and never
// re-reads it (AD's measurement, WIN11_TASKBAR_ICON.md Part 7). A window keeps one appId for its
// whole life, so fixing what an appId RESOLVES TO cannot repair a button that already exists. That
// is exactly what happened in 1.9.44: writing IconResource fixed every FUTURE button but left the
// already-created ones white, because their tiles were baked while the AUMID was still unbranded.
// Folding a generation token into the appId turns an icon change into a NEW appId, which forces the
// shell to create a NEW button, which is the ONLY thing that repaints. Cost is one button rebuild.
const PUP_ICON_GEN = 'i3';   // i3: icons rebuilt with PNG-compressed 128/256 frames (Vista+ rule)
const PUP_CAT_ICONS = {
  wiki: 'pup-cat-wiki9.ico',
  'wiki-in': 'pup-cat-wiki-in9.ico',
  app: 'pup-cat-app9.ico',
  web: 'pup-cat-web9.ico',
  mixed: 'pup-cat-mixed9.ico',
};
function classifyUrl(u) {
  try {
    if (!u) return null;
    if (isAdomUrl(u)) return 'wiki';
    const h = new URL(u).hostname;
    if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(h)) return 'app';
    // v1.9.41 (audit): ANY *.adom.cloud slug host is an Adom app, with or without a /proxy/ path.
    // The old rule demanded /proxy/, so an app served at a bare slug (project-manager-xxxx.adom.cloud)
    // was labelled "web" and wore the globe. Caught by auditing my own taskbar.
    if (/\.adom\.cloud$/i.test(h)) return 'app';
    if (/^(about|chrome|edge|devtools):/i.test(u) || u.startsWith('file:')) return null; // ignore chrome pages
    return 'web';
  } catch (e) { return null; }
}
// The window's category across ALL its tabs. One kind -> that kind. Several -> 'mixed'.
function pupCategory(sessionId) {
  try {
    const s = sessionId && sessions.get(sessionId);
    if (!s) return null;
    const kinds = new Set();
    for (const t of (s.tabs || [])) {
      let k = null;
      try { k = classifyUrl(t.page && t.page.url()); } catch (e) {}
      if (k) kinds.add(k);
    }
    if (!kinds.size) return null;
    if (kinds.size > 1) return 'mixed';
    const only = [...kinds][0];
    if (only === 'wiki' && s._wikiAuthed) return 'wiki-in';
    return only;
  } catch (e) { return null; }
}
function pupSessionAuthed(sessionId) {
  try { const s = sessionId && sessions.get(sessionId); return !!(s && s._wikiAuthed); } catch (e) { return false; }
}
function pupIconPath(sessionId) {
  const cat = pupCategory(sessionId);
  const file = (cat && PUP_CAT_ICONS[cat]) || PUP_ICON_DEFAULT;
  return path.join(__dirname, 'icons', file);
}
function pupRelaunchCommand(sessionId) {
  const cli = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Adom Desktop', 'adom-desktop-cli.exe');
  const payload = JSON.stringify({ sessionId, foregroundReason: 'user clicked the Adom Pup taskbar header or pinned launcher' }).replace(/"/g, '\\"');
  return `"${cli}" browser_focus_window "${payload}"`;
}
function _isUnknownVerb(r, raw) { return /unknown (desktop )?command|unknown verb/i.test(((r && r.error) || '') + ' ' + (raw || '')); }
async function ensurePupIdentityRegistered() {
  if (process.platform !== 'win32' || _identitySupported === false) return false;
  if (_identityRegistered) return true;
  try {
    const r = await chrome.adCommand('desktop_register_app_identity', {
      appId: PUP_APP_ID, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(),
      args: 'browser_open_window {"sessionId":"pinned","owner":"pinned-launch","url":"https://wiki.adom.inc"}',
    }, { timeoutMs: 8000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {
      if (_isUnknownVerb(r, raw)) { _identitySupported = false; console.log('[identity] AD lacks register_app_identity (needs >=1.9.134) — overlay badge stays'); }
      else console.log(`[identity] register failed: ${((r && r.error) || raw || '').slice(0, 140)}`);
      return false;
    }
    _identitySupported = true; _identityRegistered = true;
    console.log('[identity] Adom.Pup registered (HKCU AUMID + Start Menu pin -> managed pup window)');
    return true;
  } catch (e) { console.log(`[identity] register threw: ${e.message}`); return false; }
}
async function stampPupIdentity(sessionId) {
  if (process.platform !== 'win32' || !sessionId || _identitySupported === false) return false;
  if (!_identityRegistered) { await ensurePupIdentityRegistered(); if (_identitySupported === false) return false; }
  try {
    // v1.8.81: PER-SESSION AUMID (Adom.Pup.<sessionId>). A single shared AUMID made Windows
    // GROUP every pup window into ONE taskbar button — killing per-window differentiation and
    // making the tab-count overlay show whichever window last updated (John caught 4 windows
    // masquerading as one button with a wrong badge). Per-session ids restore one button per
    // window; the per-window RelaunchIconResource/RelaunchDisplayNameResource AD sets are what
    // brand an UNREGISTERED AUMID's button, and the registered plain "Adom.Pup" stays as the
    // pinnable Start Menu launcher.
    // v1.9.27 (AD's Win11 measurement, WIN11_TASKBAR_ICON.md Part 7): the taskbar TILE is baked
    // when the BUTTON is created and NEVER re-read — WM_SETICON, DeleteTab/AddTab, SHChangeNotify,
    // FRAMECHANGED, RedrawWindow, TaskbarCreated all leave it unchanged. The ONLY live repaint is
    // making the shell create a NEW button, which happens when the window is stamped with a
    // DIFFERENT appId. So the per-session appId now CARRIES THE CATEGORY:
    //     Adom.Pup.<sid>.<category>
    // A category flip = register new appId -> stamp it -> unregister the old one (~1s, no flicker,
    // AD-verified). TRADEOFF (Windows constraint, by design): a taskbar PIN binds to one AUMID, so
    // a pin made under an old category shows the running window as a separate button. Per-session
    // windows are transient and their pins launch via relaunchCommand anyway, so tile-tracks-state
    // wins here; GROUPED mode keeps the stable pinnable Adom.Pup.
    const grouped = pupSettings().taskbarGrouping === 'grouped';
    const sess = sessions.get(sessionId);
    // v1.9.52 — DO NOT stamp a transient ".plain" identity for a window that is merely still LOADING.
    // A freshly-opened window sits on about:blank for a beat (category null -> "plain"), then lands on
    // its real URL (category web/wiki/app). The old code stamped ".plain" during that beat, which
    // REGISTERED an Adom.Pup.<sid>.plain.<gen> AUMID — a full taskbar identity with NO jump list — and
    // created a button under it, then created ANOTHER button under ".category" a moment later. Windows
    // caches the button->AUMID->jumplist association per button; that plain->category churn is what
    // made the jump list "blip out and need a second right-click" (Explorer first resolved the stale
    // ".plain" association, which had no list). Skipping the plain stamp while a real URL is loading
    // means the button is created ONCE, under its final category AUMID, with its jump list already
    // attached — so the menu resolves on the first click. A genuinely category-less window (a page
    // that never leaves about:blank) still gets "plain" after a few attempts so it is never unbranded.
    const rawCat = pupCategory(sessionId);
    if (!grouped && !rawCat) {
      const hasRealUrlLoading = (sess && (sess.tabs || []).some(t => {
        try { const u = t.page && t.page.url(); return u && !/^(about:blank|chrome|edge|devtools):/i.test(u); } catch (e) { return false; }
      }));
      const attempts = (sess && sess._plainStampAttempts) || 0;
      if (hasRealUrlLoading || attempts < 6) {
        if (sess) sess._plainStampAttempts = attempts + 1;
        return false; // pending — the retry loop / framenavigated recheck re-stamps once the category is known
      }
    }
    if (sess) sess._plainStampAttempts = 0;
    const cat = rawCat || 'plain';
    const appId = grouped ? PUP_APP_ID : `${PUP_APP_ID}.${sessionId}.${cat}.${PUP_ICON_GEN}`;
    const prevAppId = sess && sess._curAppId;
    // Register the NEW appId BEFORE stamping (AD's verified order) so the fresh button resolves
    // its icon/name from the registry the moment it is created.
    if (!grouped && prevAppId !== appId) {
      try { await registerPupAumid(appId, sessionId); } catch (e) {}
      // v1.9.61 — ORDER MATTERS (the live-tile trap, from AD's window-identity skill): Win11 bakes a
      // taskbar button's art when the button is CREATED and never re-reads it. Stamping the new appId
      // below is what creates that button. We used to brand the jump-list header AFTER the stamp, so
      // the header baked GENERIC and the later headerBranded:true had no visible effect — exactly what
      // John kept seeing. Brand the header (writes the window's Relaunch trio) FIRST, so the button is
      // born with it.
      try { await updateWikiJumplist(sess, sessionId, { appId }); } catch (e) {}
    }
    const r = await chrome.adCommand('desktop_set_window_identity', {
      titleContains: `(session: ${sessionId}`, cacheKey: `pup-${sessionId}-${cat}`,
      appId, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(sessionId),
      // v1.9.11 (AD ≥1.9.141): brand the taskbar RIGHT-CLICK MENU HEADER row (it showed the
      // chrome.exe icon for months). There is NO "ApplicationIcon" registry value on AUMID keys
      // and the registry cannot brand the header at all — the header obeys the window's
      // Relaunch* property TRIO, and Windows IGNORES RelaunchIconResource unless RelaunchCommand
      // is ALSO set. So we must pass a real relaunch command. It doubles as what a header click
      // or a taskbar PIN launches, so it has to genuinely refocus THIS session: browser_focus_window
      // carries a foregroundReason (the raise gate refuses without one, and a header click IS a
      // real user request to see the window). Quotes are backslash-escaped for Windows argv —
      // same trap that made jump-list clicks no-op in 1.9.9.
      relaunchCommand: pupRelaunchCommand(sessionId),
    }, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {
      if (_isUnknownVerb(r, raw)) _identitySupported = false;
      else console.log(`[identity] "${sessionId}" stamp FAILED (appId=${appId}): ${((r && r.error) || raw || 'no response').slice(0, 140)}`);
      return false;
    }
    const o = raw ? JSON.parse(raw) : {};
    const data = o.data || o;
    const ok = !!(data.ok || (data.applied && data.applied.length));
    if (!ok) console.log(`[identity] "${sessionId}" stamp NOT applied (appId=${appId}): ${(raw || '').slice(0, 140)}`);
    if (ok) {
      console.log(`[identity] "${sessionId}" stamped appId=${appId}: ${(data.applied || []).join(',') || 'ok'}`);
      if (sess && !grouped && prevAppId !== appId) {
        sess._curAppId = appId;
        // The jump list attaches to the AUMID — a new appId starts with NO jump list, so force a
        // re-attach on the next updateWikiJumplist pass.
        updateWikiJumplist(sess, sessionId).catch(() => {});   // deduped by _wikiJumplistKey
        // Unregister the superseded appId (cleanup, AD step 3). Never the pinnable base id.
        if (prevAppId && prevAppId !== PUP_APP_ID) { unregisterPupAumid(prevAppId).catch(() => {}); }
      }
    }
    return ok;
  } catch (e) { return false; }
}
// v1.9.27: registrations are keyed by the FULL category-carrying appId. The category is baked
// into the id itself (Adom.Pup.<sid>.<cat>), so a Set suffices — a state change is a NEW id.
const _registeredPupAumids = new Set();
async function registerPupAumid(appId, sessionId) {
  if (_identitySupported === false || appId === PUP_APP_ID || _registeredPupAumids.has(appId)) return;
  // v1.9.44: write IconResource FIRST, before the button can possibly exist. AD's registration does
  // not write it (see writeAumidIcon), and an AUMID with no icon is what paints white.
  writeAumidIcon(appId, pupIconPath(sessionId));
  try {
    const r = await chrome.adCommand('desktop_register_app_identity', {
      appId, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(sessionId), shortcut: false,
    }, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if (_isUnknownVerb(r, raw)) return; // older AD without #207 — the per-window stamp still brands the taskbar button
    _registeredPupAumids.add(appId);
    console.log(`[identity] registered AUMID ${appId} (registry-only, icon=${pupCategory(sessionId) || 'plain'})`);
  } catch (e) {}
}
// v1.9.44 — WHY THE TASKBAR ICON GOES WHITE, and the fix. READ BEFORE TOUCHING ICON CODE.
//
// MEASURED on John's box: 129 of 129 HKCU AppUserModelId\Adom.Pup.* keys had IconResource = NULL.
// AD's desktop_register_app_identity ACCEPTS an iconPath but only materialises it when it also
// builds a Start Menu shortcut; with shortcut:false (our per-session path) the AUMID key is created
// with a DisplayName and NO IconResource at all. So the registry branded nothing, ever.
//
// Everything still LOOKED right because the per-window Relaunch* properties that
// desktop_set_window_identity sets do brand the button. But those are a RACE against Win11 baking
// the tile at button-creation time. Win the race -> correct icon. Lose it -> Windows has nothing to
// resolve for that AUMID and paints the GENERIC WHITE DOCUMENT. That is why the icon was white on
// ONE window (a long-lived session whose button predated its stamp) and correct on its siblings —
// it was never a category or icon-file problem, it was an unbranded AUMID plus a lost race.
//
// FIX: write IconResource into HKCU ourselves. HKCU needs no UAC, this bridge already runs ON the
// desktop (no AD roundtrip, no shell-approval prompt), and a registry value is not a race — it is
// there before and after the button exists. Keep the Relaunch* props too; they are what repaint a
// LIVE category flip. Belt and braces.
//
// If icons go white again, check IconResource FIRST (see pup-bridge-debug -> "white taskbar icon").
function writeAumidIcon(appId, iconPath) {
  if (process.platform !== 'win32' || !appId || !iconPath) return false;
  try {
    if (!fs.existsSync(iconPath)) { console.log(`[identity] icon MISSING on disk: ${iconPath}`); return false; }
    const key = `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`;
    // ",0" selects the first frame. reg.exe is used directly (not PowerShell) — no window, no profile
    // load, ~10ms, and it creates the key if the AD registration has not landed yet.
    execFileSync('reg', ['add', key, '/v', 'IconResource', '/t', 'REG_SZ', '/d', `${iconPath},0`, '/f'],
      { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });
    return true;
  } catch (e) { console.log(`[identity] IconResource write failed for ${appId}: ${e.message}`); return false; }
}
// v1.9.44: the AUMID keys LEAKED. A new key is minted per session per category, but only the
// immediately-superseded one is unregistered, and only in-process — so every bridge restart orphaned
// its whole set (129 keys had accumulated). Sweep on startup: drop any Adom.Pup.<sid>.* key whose
// <sid> is not a live session. Never touch the pinnable base id.
function pruneStaleAumids() {
  if (process.platform !== 'win32') return;
  try {
    const out = execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'],
      { encoding: 'utf8', timeout: 8000, windowsHide: true });
    const live = new Set(sessions.keys());
    let dropped = 0;
    for (const line of out.split(/\r?\n/)) {
      const m = line.match(/\\(Adom\.Pup\.(.+))$/);
      if (!m) continue;
      const appId = m[1], rest = m[2];
      if (appId === PUP_APP_ID) continue;                 // the pinnable launcher stays
      const sid = rest.replace(/\.i\d+$/, '').replace(/\.(wiki-in|wiki|app|web|mixed|plain)$/, '');
      if (live.has(sid)) continue;
      try {
        execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/f'],
          { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });
        dropped++;
      } catch (e) {}
    }
    if (dropped) console.log(`[identity] pruned ${dropped} stale Adom.Pup.* AUMID key(s) from HKCU`);
  } catch (e) {}
}
async function unregisterPupAumid(appId) {
  if (process.platform !== 'win32' || !appId || appId === PUP_APP_ID) return;
  _registeredPupAumids.delete(appId);
  // v1.9.52: CLEAR this AUMID's jump list before unregistering it. Otherwise its CustomDestinations
  // file lingers in the shell (measured: 6 stale jump-list files for 3 live windows, because every
  // category flip mints a new AUMID and orphans the old one's list). Explorer resolving a button
  // against a pile of stale-but-present jump lists is part of the first-right-click "blip".
  if (_jumplistSupported !== false) {
    try { await chrome.adCommand('desktop_set_window_jumplist', { appId, tasks: [] }, { timeoutMs: 5000 }); } catch (e) {}
  }
  try { await chrome.adCommand('desktop_unregister_app_identity', { appId }, { timeoutMs: 5000 }); } catch (e) {}
}

// v1.9.12: record the wiki auth state and RE-BRAND if it changed, so the taskbar icon flips
// between the browser-window glyph and the solid padlock the moment login state is known.
// Returns true if the state changed (i.e. a re-stamp was issued).
async function setWikiAuthedState(session, sessionId, authed) {
  if (!session) return false;
  const next = !!authed;
  if (session._wikiAuthed === next) return false;
  session._wikiAuthed = next;
  if (process.platform !== 'win32') return true;
  console.log(`[identity] "${sessionId}" wiki login state -> ${next ? 'LOGGED IN (solid green glyph)' : 'logged out (hollow glyph)'}`);
  // v1.9.15: RETRY the re-stamp. After a view toggle the window is BRAND NEW and AD resolves it by
  // its "(session: <id>)" title suffix, which the title injector adds only once the relaunched page
  // has loaded — so a single immediate stamp loses the race and AD answers "No visible window with
  // title containing (session: ...)". That is exactly why the icon did not flip on the first try.
  // Retry with backoff (~0.8s,1.6s,2.4s,3.2s,4s) until the title exists; the AUMID re-register runs
  // after, so Alt-Tab/flyout get the matching icon too.
  let stamped = false;
  for (let i = 0; i < 5 && !stamped; i++) {
    try { stamped = await stampPupIdentity(sessionId); } catch (e) {}
    if (!stamped) await new Promise(r => setTimeout(r, 800 * (i + 1)));
  }
  if (!stamped) console.log(`[identity] "${sessionId}" icon re-stamp FAILED after retries — window title never matched`);
  return true;
}
// v1.9.3 (AD #208 — desktop_set_window_jumplist, AD ≥1.9.139): give each WIKI pup window a
// taskbar RIGHT-CLICK task to flip between the logged-in and public view. The jump list attaches
// to the per-session AUMID (registered registry-only in v1.9.2), so it is a PER-WINDOW menu.
// Clicking a task runs AD's bundled CLI → browser_wiki_set_view, which relaunches THIS window
// under the other cookie jar (profile = userDataDir is fixed at browser launch, so a view switch
// = a relaunch). Only meaningful in SPLIT mode (grouped mode shares one taskbar button, so a
// per-window menu would be ambiguous) and only for Adom (adom.inc) URLs; any other URL clears the
// menu. Self-gates off if AD lacks the verb. Idempotent per (session, view) via _wikiJumplistView.
async function updateWikiJumplist(session, sessionId, opts) {
  if (process.platform !== 'win32' || _identitySupported !== true || _jumplistSupported === false) return;
  if (!session || pupSettings().taskbarGrouping === 'grouped') return;
  // v1.9.61: callers may pass the appId EXPLICITLY. stampPupIdentity brands the header BEFORE it
  // stamps the new appId onto the window, so at that moment session._curAppId is still the OLD id.
  const appId = (opts && opts.appId) || (session && session._curAppId) || `${PUP_APP_ID}.${sessionId}`;
  let url = null; try { url = (session.page && session.page.url()) || null; } catch (e) {}
  const view = isAdomUrl(url) ? (session._wikiView === 'authed' ? 'authed' : 'public') : null;
  // v1.9.51: the jump-list STATE is now (view + a constant close-all task). The close-all task is
  // on EVERY pup window, so the idempotency key is just the view — a menu that already reflects this
  // view already has the close-all row too.
  // v1.9.61: key on (appId|view). Keying on view alone re-committed the list on every refresh once
  // the appId changed, and EVERY CommitList rebuilds the menu — if one lands while the user is
  // opening it, the shell dismisses it. That is the "first right-click does nothing, second works"
  // wonkiness. One commit per (button, view), never a redundant one.
  const jlKey = `${appId}|${view || 'none'}`;
  if (session._wikiJumplistKey === jlKey) return;
  // v1.9.62: CLAIM the key before the await. Two callers (the pre-stamp brand and a timer-driven
  // refresh) can both compute the same key and both find it unset, so both commit — a redundant
  // CommitList that rebuilds the menu and can dismiss one the user is opening. Claiming up front
  // makes the second caller a no-op; we roll it back below if the call actually fails.
  const _prevJlKey = session._wikiJumplistKey;
  session._wikiJumplistKey = jlKey;
  // v1.9.6: give each task an icon (iconPath) — without it Windows shows a generic blank-document
  // glyph (John: "why is your icon generic for that menu item?").
  // v1.9.54: use THIS WINDOW'S category icon, not pupIconPath() with no arg — that argless call fell
  // back to PUP_ICON_DEFAULT (adom-pup-browser3.ico), the RETIRED "P + card" pup icon, so the menu
  // rows still wore the old brand. pupIconPath(sessionId) returns the new teal-tile category art
  // (pup-cat-<cat>9.ico), so the task rows match the window's own taskbar icon.
  const _taskIcon = pupIconPath(sessionId);
  // v1.9.9: WINDOWS ARGV QUOTING — THE reason clicking a task did NOTHING. The jump-list task line is
  // parsed by the CLI's argv parser, which STRIPS unescaped double quotes, so raw JSON arrived
  // mangled and the CLI died with `Invalid JSON args`. Wrap JSON in quotes with the inner quotes
  // BACKSLASH-escaped so argv delivers it intact as ONE argument. Any JSON-carrying task MUST use this.
  const jarg = (verb, obj) => `${verb} "${JSON.stringify(obj).replace(/"/g, '\\"')}"`;
  const taskArgs = (v) => jarg('browser_wiki_set_view', { sessionId, view: v });
  // v1.9.51: a CLOSE-ALL task on every pup window (John: "i have like 8 open right now"). It calls
  // browser_close with no sessionId, which kills every pup Chrome + clears every session. The empty
  // "{}" still goes through the same escaped-argv path so the CLI parses it as one JSON argument.
  const closeAllTask = {
    title: 'Close ALL Adom Pup windows',
    description: 'Close every Adom Pup browser window at once (all sessions, every tab)',
    iconPath: _taskIcon,
    args: jarg('browser_close', {}),
  };
  // v1.9.53: SHORT titles. John: the "Adom wiki:" prefix pushed the important word ("logged-in")
  // off the end where Windows truncates the task label ("...switch to logged-in vi…"). The whole
  // point of the toggle is the login state, so lead with that and let the DESCRIPTION carry the
  // "Adom wiki page" context (it renders on its own line, untruncated). Keep both under ~24 chars so
  // neither clips at the default jump-list width.
  let tasks = [];
  if (view === 'authed') {
    tasks.push({ title: 'Switch to public view', description: 'Reload this Adom wiki page logged OUT — exactly what the public/world sees', iconPath: _taskIcon, args: taskArgs('public') });
  } else if (view === 'public') {
    tasks.push({ title: 'Switch to logged-in view', description: 'Reload this Adom wiki page SIGNED IN — private source, drafts, owner-only cards', iconPath: _taskIcon, args: taskArgs('authed') });
  }
  tasks.push(closeAllTask); // always present, listed after any window-specific task
  // The shell ignores a jump list on an unregistered AUMID — ensure it's registered first.
  if (!_registeredPupAumids.has(appId)) {
    await registerPupAumid(appId, sessionId);
    if (!_registeredPupAumids.has(appId)) return;
  }
  // v1.9.60 — BRAND THE JUMP-LIST HEADER ROW (AD >= 1.9.153).
  //
  // The header row (the "Adom Pup" app tile above Pin/Close) is branded by passing `headerIcon` to
  // desktop_set_window_jumplist. TWO non-obvious requirements, both measured:
  //   1. You MUST pass `hwnd`. With only appId, AD cannot locate the window to write the per-window
  //      Relaunch* props on and silently returns headerBranded:false with an EMPTY headerFields and
  //      NO error. `titleContains` does NOT work here either (also headerBranded:false) even though
  //      it works on desktop_set_window_identity. hwnd is the only locator that brands the header.
  //   2. The .ico must pass AD's iconCheck — in particular its 256px entry must be PNG-compressed,
  //      or the shell paints the generic white document regardless (the Vista+ rule that had all of
  //      our icons failing until 1.9.58).
  // The response carries headerBranded / headerFields / headerIconCheck, so this is verifiable in
  // code — no more "right-click it and tell me what you see".
  let hwnd = null;
  try {
    const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 }));
    hwnd = fw && fw.best && fw.best.hwnd;
  } catch (e) {}
  try {
    const payload = { appId, tasks };
    if (hwnd) { payload.hwnd = hwnd; payload.headerIcon = pupIconPath(sessionId); }
    const r = await chrome.adCommand('desktop_set_window_jumplist', payload, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if (_isUnknownVerb(r, raw)) { _jumplistSupported = false; session._wikiJumplistKey = _prevJlKey; return; }
    _jumplistSupported = true;
    session._wikiJumplistView = view;
    session._wikiJumplistKey = jlKey;
    // Surface the header-branding outcome: it is the ONLY programmatic signal that the app-tile row
    // actually took our icon (the rendered menu itself cannot be captured from the cloud).
    let hb = null, hic = null;
    try { const o2 = raw ? JSON.parse(raw) : {}; const d2 = o2.data || o2; hb = d2.headerBranded; hic = d2.headerIconCheck; } catch (e) {}
    console.log(`[jumplist] "${sessionId}" set ${tasks.length} task(s) on ${appId} (view=${view || 'none'}${view ? '+' : ''}close-all) headerBranded=${hb}${hic && hic.ok === false ? ` ICONCHECK_FAILED:${(hic.issues || []).join('; ')}` : ''}`);
  } catch (e) { session._wikiJumplistKey = _prevJlKey; }
}
// v1.8.80: TAB-COUNT overlay badge (John: "if the ai opens 8 tabs that makes that pup window
// unique"). The overlay slot is FREE on identity-capable AD (the base icon is the Adom Pup
// identity), so it now shows the window's tab count for >=2 tabs — dark circle, white digit,
// 9+ cap. NO badge = 1 tab, so a many-tab window is instantly distinguishable from its
// single-tab neighbors. On AD <1.9.134 (no identity) the overlay stays the pup brand badge —
// branding beats counting there. Updated on open_tab/close_tab/popup-attach; cleared at 1.
const _tabBadgeB64 = new Map(); // count-key -> base64 png (lazy)
function tabBadgeIcon(count) {
  const key = count > 9 ? '9plus' : String(count);
  if (_tabBadgeB64.has(key)) return _tabBadgeB64.get(key);
  try {
    const b = fs.readFileSync(path.join(__dirname, 'icons', 'tab-badges', `badge-${key}.png`)).toString('base64');
    _tabBadgeB64.set(key, b);
    return b;
  } catch (e) { return null; }
}
// v1.8.84: DUAL counter for grouped mode (John: "1 for # of windows and then a total for the
// tabs"). Windows = dark circle upper-right (same language as the split badge); total tabs =
// light teal circle lower-left with dark text. Combos are dynamic -> rendered at runtime from
// an SVG template via sharp (already a dep), cached in memory per (wins,tabs) pair. If sharp
// is unavailable (optional dep), grouped mode falls back to the static windows-count badge.
const _dualBadgeCache = new Map();
async function dualBadgeB64(wins, tabs) {
  const key = `${wins}/${tabs}`;
  if (_dualBadgeCache.has(key)) return _dualBadgeCache.get(key);
  let sharpMod = null;
  try { sharpMod = require('sharp'); } catch (e) { return null; }
  const w = wins > 9 ? '9+' : String(wins);
  const t = tabs > 99 ? '99+' : String(tabs);
  const wSize = w.length > 1 ? 11 : 14;
  const tSize = t.length > 2 ? 7.8 : (t.length > 1 ? 9.8 : 13);
  // v1.8.90: ROUNDED-RECT plates behind the digits (per John — bare digits were hard to see
  // without a background; rects fit digits tighter than circles so the type stays big).
  // Left dark rect = windows (white digit); right teal rect = total tabs (dark digit).
  const wS = w.length > 1 ? 11 : 14;
  const tS = t.length > 2 ? 8 : (t.length > 1 ? 10.5 : 14);
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <rect x="0.5" y="1" width="14.5" height="15" rx="4.5" fill="#003d40"/>
  <text x="7.75" y="${8.5 + wS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${wS}" font-weight="700" fill="#ffffff" text-anchor="middle">${w}</text>
  <rect x="17" y="1" width="14.5" height="15" rx="4.5" fill="#7de8e2"/>
  <text x="24.25" y="${8.5 + tS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${tS}" font-weight="700" fill="#003d40" text-anchor="middle">${t}</text>
</svg>`;
  try {
    const buf = await sharpMod(Buffer.from(svg)).resize(32, 32).png().toBuffer();
    const b64 = buf.toString('base64');
    _dualBadgeCache.set(key, b64);
    return b64;
  } catch (e) { return null; }
}

// v1.9.57 — AGENT-ACTIVITY INDICATOR (taskbar progress bar).
//
// John runs many AI threads at once and asked which pup window an agent is actually driving right
// now. The taskbar PROGRESS bar answers it with zero AD work: ITaskbarList3 progress is already
// exposed by desktop_taskbar, it paints ON the button (so it reads at a glance across the whole
// taskbar), and it is a SEPARATE channel from the overlay icon — the favicon overlay is untouched.
//
// Semantics: 'indeterminate' (marquee) while a thread is issuing verbs at this window, cleared
// ~2.5s after the last one. Deliberately NOT a percentage: an agent's work has no known length, and
// a fake percentage would be a lie. Only transitions are sent to AD (idle->busy once, busy->idle
// once), never one call per verb, so a chatty thread costs 2 AD calls, not 200.
const _busyState = new Map();      // sessionId -> { busy, timer }
const BUSY_IDLE_MS = 2500;
async function setSessionProgress(sessionId, busy) {
  if (process.platform !== 'win32') return;
  try {
    await chrome.adCommand('desktop_taskbar', {
      titleContains: `(session: ${sessionId}`,
      progress: busy ? { state: 'indeterminate' } : { state: 'none' },
    }, { timeoutMs: 4000 });
  } catch (e) {}
}
function markSessionBusy(sessionId) {
  if (process.platform !== 'win32' || !sessionId) return;
  if (pupSettings().agentActivity === 'off') return;      // user opt-out via browser_configure
  if (!sessions.has(sessionId)) return;
  let st = _busyState.get(sessionId);
  if (!st) { st = { busy: false, timer: null }; _busyState.set(sessionId, st); }
  if (!st.busy) {
    st.busy = true;
    console.log(`[activity] "${sessionId}" agent driving — taskbar progress ON`);
    setSessionProgress(sessionId, true).catch(() => {});
  }
  clearTimeout(st.timer);
  st.timer = setTimeout(() => {
    st.busy = false;
    console.log(`[activity] "${sessionId}" idle — taskbar progress OFF`);
    setSessionProgress(sessionId, false).catch(() => {});
  }, BUSY_IDLE_MS);
}
function clearSessionBusy(sessionId) {
  const st = _busyState.get(sessionId);
  if (st) { clearTimeout(st.timer); _busyState.delete(sessionId); }
}
// Verbs that are pure status/polling: they do NOT mean an agent is driving the window, and lighting
// the bar for them would make every window look permanently busy (the audit loops alone poll these).
const PASSIVE_VERBS = new Set([
  'browser_list_windows', 'browser_list_tabs', 'browser_readiness', 'browser_status',
  'browser_describe', 'browser_configure', 'browser_prewarm', 'browser_rescan', 'browser_wait',
]);

// v1.9.58 — THUMBNAIL TOOLTIP (AD >= 1.9.152: desktop_taskbar { thumbnailTooltip }).
//
// The hover-preview CAPTION is just the window title and Windows truncates it at ~25-30 chars, so it
// can only ever carry the state glyph plus a clipped page name. The thumbnail tooltip is the only
// UNCAPPED text surface on that popup and it accepts NEWLINES, so this is where the full answer to
// "what is this window?" belongs: that it is Adom Pup, what KIND of content it holds, whether a wiki
// page is the public or the signed-in view, that it is Chrome-driven, and the tab count.
const CAT_LABEL = {
  wiki: 'Adom wiki (PUBLIC view)',
  'wiki-in': 'Adom wiki (SIGNED IN)',
  app: 'Adom app',
  web: 'Web page',
  mixed: 'Mixed content',
};
function buildThumbnailTooltip(session, sessionId) {
  const cat = pupCategory(sessionId);
  const tabs = (session.tabs || []).length;
  const lines = [`Adom Pup · ${CAT_LABEL[cat] || 'Browser window'}`];
  try {
    if (cat === 'mixed') {
      // Name the distinct hosts so the user knows WHY it is mixed without opening it.
      const hosts = [];
      for (const t of (session.tabs || [])) {
        try { const h = new URL(t.page.url()).hostname.replace(/^www\./, ''); if (h && !hosts.includes(h)) hosts.push(h); } catch (e) {}
      }
      if (hosts.length) lines.push(hosts.slice(0, 4).join(', '));
    } else {
      const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];
      if (active) {
        let u = ''; try { u = active.page.url(); } catch (e) {}
        if (u) { try { const p = new URL(u); lines.push((p.hostname + p.pathname).replace(/^www\./, '').slice(0, 70)); } catch (e) {} }
      }
    }
  } catch (e) {}
  lines.push(`Chrome for Testing · ${tabs} tab${tabs === 1 ? '' : 's'}`);
  return lines.join('\n');
}
async function updateThumbnailTooltip(session, sessionId) {
  if (process.platform !== 'win32' || !session || _thumbTooltipSupported === false) return;
  const tip = buildThumbnailTooltip(session, sessionId);
  if (session._thumbTip === tip) return;   // idempotent: only push real changes
  try {
    const r = await chrome.adCommand('desktop_taskbar', {
      titleContains: `(session: ${sessionId}`, thumbnailTooltip: tip,
    }, { timeoutMs: 5000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if (_isUnknownVerb(r, raw) || /unknown (arg|field)|unexpected (arg|field)/i.test(raw || '')) {
      _thumbTooltipSupported = false;
      console.log('[thumbtip] AD lacks thumbnailTooltip (needs >=1.9.152) — skipping');
      return;
    }
    _thumbTooltipSupported = true;
    session._thumbTip = tip;
    console.log(`[thumbtip] "${sessionId}" -> ${tip.replace(/\n/g, ' | ')}`);
  } catch (e) {}
}
let _thumbTooltipSupported = null;

async function updateTabCountBadge(session, sessionId) {
  if (process.platform !== 'win32' || _identitySupported !== true) return; // overlay busy with brand badge on old AD
  // v1.9.31: a tab verb just ran — cheap moment to notice a drag-out (event-driven, not a scan).
  drainWindowOpenedEvents().catch(() => {});
  // v1.9.59: refresh the hover-preview TOOLTIP here. It was only wired into refreshWindowChrome
  // (drag-out / reclaim / relaunch) and the category-change hook, so the NORMAL OPEN PATH — which
  // calls brandPupWindow directly and never touches either — never set a tooltip at all, and no
  // window that just opened had one. This function is the right home: it already runs on open and on
  // every tab open/close, which is exactly when the tooltip's content (category + tab count) changes.
  // It is idempotent (session._thumbTip) so repeat calls cost nothing.
  updateThumbnailTooltip(session, sessionId).catch(() => {});
  // v1.9.22: if the window's CATEGORY changed (a tab made it mixed, a navigate left the wiki...),
  // re-stamp the base icon NOW — the 10-minute brand debounce is for redundant re-stamps of the
  // SAME icon, not for a category flip the user should see immediately.
  try {
    const cat = pupCategory(sessionId);
    if (cat !== session._lastCat) {
      session._lastCat = cat;
      // v1.9.27: the stamp now swaps to a category-carrying appId, which makes the shell create a
      // FRESH button with the new tile (the only repaint Win11 allows) and handles the AUMID
      // register/unregister + jump-list re-attach itself. v1.9.28: RETRY with backoff — a single
      // silent failure here left the tile latched on the old category (seen live: .mixed AUMID
      // registered but the stamp never applied).
      (async () => {
        for (let i = 0; i < 4; i++) {
          let ok = false;
          try { ok = await brandPupWindow(sessionId); } catch (e) {}
          if (ok) { session._badgedAt = Date.now(); return; }
          await new Promise(r => setTimeout(r, 900 * (i + 1)));
        }
        session._lastCat = undefined;   // same reason as above: never cache an unlanded stamp
        console.log(`[identity] "${sessionId}" category re-stamp exhausted retries (wanted ${cat}) — cache cleared`);
      })().catch(() => {});
    }
  } catch (e) {}
  try {
    // grouped mode: the single stacked button's badge = WINDOW count (per-window tab counts
    // are meaningless on a grouped button). Applied to every window; last write paints the group.
    if (pupSettings().taskbarGrouping === 'grouped') {
      const wins = sessions.size;
      let totalTabs = 0;
      for (const [, s2] of sessions) totalTabs += (s2.tabs || []).length;
      let icon = null;
      if (wins >= 2) icon = (await dualBadgeB64(wins, totalTabs)) || tabBadgeIcon(wins);
      else if (totalTabs >= 2) icon = tabBadgeIcon(totalTabs); // single window: plain tab count
      for (const [sid2] of sessions) {
        const args2 = icon
          ? { titleContains: `(session: ${sid2}`, overlay: { icon, tooltip: `Adom Pup — ${wins} window${wins === 1 ? '' : 's'}, ${totalTabs} tab${totalTabs === 1 ? '' : 's'}` } }
          : { titleContains: `(session: ${sid2}`, overlay: { badge: 'none' } };
        try { await chrome.adCommand('desktop_taskbar', args2, { timeoutMs: 5000 }); } catch (e) {}
      }
      return;
    }
    // v1.9.0 split-mode overlay RULE (John: favicons > counts): the OVERLAY is the ACTIVE tab's
    // site favicon, whatever the tab count — you always see what you're looking at. The tab count
    // rides in the hover tooltip. Only if the active page has NO favicon do we fall back to a
    // plain count badge (so a many-tab faviconless window isn't a blank Adom tile).
    const count = (session.tabs || []).length;
    const worn = await applyAppOverlay(session, sessionId); // active tab's favicon + count in tooltip
    if (worn) return;
    session._appOverlay = false;
    if (count >= 2) {
      const icon = tabBadgeIcon(count);
      if (!icon) return;
      await chrome.adCommand('desktop_taskbar', {
        titleContains: `(session: ${sessionId}`,
        overlay: { icon, tooltip: `Adom Pup — ${count} tabs` },
      }, { timeoutMs: 5000 });
    } else {
      await chrome.adCommand('desktop_taskbar', {
        titleContains: `(session: ${sessionId}`,
        overlay: { badge: 'none' },
      }, { timeoutMs: 5000 });
    }
  } catch (e) {}
}

// v1.8.79: BRAND SWEEP — unify every live window to the CURRENT identity art. Windows keep
// the identity they were stamped with at birth (per-HWND, AD-cached HICON), so after an icon
// rebrand or a bridge update the taskbar shows MIXED GENERATIONS (John caught a museum of
// today's six releases). The sweep re-asserts each session's title suffix (title churn can
// strand a window un-resolvable) then re-stamps it. Runs at startup (post-reattach) and after
// every rescan.
async function brandSweep(reason) {
  // v1.9.41 (audit): re-stamp CATEGORY icons for every live session. Windows that predate the
  // category work, or that were recovered across a bridge restart, kept the plain default tile
  // forever because nothing re-evaluated them. Found by auditing my own taskbar: a long-lived
  // window sat on the default icon while its neighbours showed proper categories.
  try {
    for (const [sid, sess] of [...sessions]) {
      sess._lastCat = undefined;               // force a real re-evaluation
      refreshWindowChrome(sess, sid).catch(() => {});
    }
  } catch (e) {}

  if (process.platform !== 'win32') return;
  let n = 0;
  for (const [sid, sess] of sessions) {
    try {
      const tab = sess.tabs && sess.tabs[0];
      if (!tab || !tab.page) continue;
      // re-assert the title suffix so AD's resolver can find the window even after title churn
      try {
        await tab.page.evaluate((suffix) => {
          try { if (document.title.indexOf(suffix) === -1) document.title += suffix + ')'; } catch (e) {}
        }, ` (session: ${sid}`);
      } catch (e) {}
      const ok = await brandPupWindow(sid);
      if (ok) n++;
    } catch (e) {}
  }
  if (n) console.log(`[brand-sweep] (${reason}) re-branded ${n} window(s) to current identity`);
}

// v1.8.92: APP-FAVICON overlay (John's standard: a pup window driven by an app WEARS that
// app's icon). Source of truth = the page's own <link rel="icon"> — the exact artifact the
// adom-ui-design standard mandates every app serve (monochrome white mark; see hd-tab-icons:
// the served favicon is the canonical webview identity, and pup now honors the same contract).
// Explicit overrides: browser_open_window {appIconB64|appIconUrl, appName}. Composited onto a
// dark rounded plate via sharp (white marks need dark backing on the taskbar). An app-looking
// URL (localhost / *.adom.cloud proxy) serving NO favicon gets a loud reprimand hint teaching
// the standard. When an app icon is worn, it takes the overlay slot; the tab count moves to
// the hover tooltip (grouped mode's dual counter is unaffected).
// v1.8.93: WIKI DUAL-VIEW + AUTH. pup drives a fresh anonymous profile by default → the wiki
// renders LOGGED OUT (public view). For the LOGGED-IN view, route to a reserved PERSISTENT
// profile whose on-disk cookie jar holds the SSO session (30-day) — log in once via SSO, reused
// for a month, re-auth only on expiry. No token→cookie exchange exists (wiki auth is cookie-based
// SSO to hydrogen.adom.inc; the container's CLI bearer is a different credential). The two views
// together let a user VERIFY publish visibility — e.g. "did source leak to the public?" (a
// hide_source page shows source to authed, binaries-only to public).
const WIKI_AUTH_PROFILE = 'adom-wiki-authed';
function isWikiUrl(u) { try { return /(^|\.)wiki\.adom\.inc$/i.test(new URL(u).hostname); } catch (e) { return false; } }
// v1.8.99: the SSO session covers the whole Adom ecosystem, not just the wiki. Gate the authed
// treatment on ANY adom.inc host (wiki + other Adom properties). NON-Adom sites (ti.com,
// digikey.com, even adom.cloud proxies) get NOTHING — no auth routing, hints, or glyph.
function isAdomUrl(u) { try { const h = new URL(u).hostname; return h === 'adom.inc' || /\.adom\.inc$/i.test(h); } catch (e) { return false; } }
// Ask the page (using ITS cookies) whether the wiki session is authenticated.
async function checkWikiAuth(page) {
  try {
    return await page.evaluate(async () => {
      try { const r = await fetch('/auth/me', { credentials: 'same-origin' }); if (!r.ok) return { authed: false }; const j = await r.json(); return { authed: !!(j && j.user), user: j && j.user && (j.user.displayName || j.user.username) }; }
      catch (e) { return { authed: false }; }
    });
  } catch (e) { return { authed: false }; }
}
// v1.8.97: gentle IN-TAB mode indicator (John: "indicate which mode that pup window is in ...
// in the chrome ui in a gentle way"). Can't touch the native tab-strip empty space (browser UI,
// not reachable), but the TAB TITLE is: a single leading monochrome glyph shows on the tab.
// U+25CF solid = logged-in wiki view, U+25CB hollow = public/anonymous (geometric text glyphs,
// NOT emoji, per the brand icon law). Idempotent + MutationObserver so the SPA can't wipe
// it, and it doesn't fight the session-suffix injector (that touches the END, this the START).
// v1.9.1: WORD label, not a cryptic emoji (John saw a bare 📖 and had no idea what it meant).
// The tab title leads with "🔓 Logged in · " or "📖 Public · " so the mode is unmistakable.
async function setWikiModeGlyph(page, label) {
  const fn = (lbl) => {
    try {
      window.__pupWikiLabel = lbl;
      const prefix = lbl + ' · ';
      // v1.9.19: strip the CURRENT monochrome prefixes and any LEGACY emoji one, so a window
      // carried over from an older bridge heals instead of stacking two prefixes.
      const strip = (t) => t.replace(/^(?:[\u25CF\u25CB]|\uD83D[\uDD13\uDCD6])\s?(?:Logged in|Public)\s\u00b7\s/, '');
      const ensure = () => { try { const cur = document.title || ''; if (!cur.startsWith(prefix)) document.title = prefix + strip(cur); } catch (e) {} };
      ensure();
      const t = document.querySelector('title') || document.head || document.documentElement;
      if (t && !window.__pupWikiGlyphObs) { try { window.__pupWikiGlyphObs = new MutationObserver(ensure); window.__pupWikiGlyphObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {} }
    } catch (e) {}
  };
  try { await page.evaluateOnNewDocument(fn, label); } catch (e) {}
  try { await page.evaluate(fn, label); } catch (e) {}
}

function wikiViewHint(url, view, auth) {
  if (!isAdomUrl(url)) return '';
  if (view === 'authed') {
    if (auth && auth.authed) return ` 🔓 Wiki LOGGED-IN view (as ${auth.user || 'the user'}) — private source, drafts, owner-only cards, reply/edit. This authed session is SHARED: every thread's wikiView:"authed" is logged in too (one login served it). For the PUBLIC view (what the world sees — e.g. verify a publish didn't leak private source), open WITHOUT wikiView.`;
    return ` 🔑 Wiki authed view requested, but NO ONE has done the one-time login yet (or the ~30-day session expired) — so this window is currently PUBLIC/logged-out. A thread can't self-login (it's the user's SSO). Offer it: browser_raise_os_window {foregroundReason:"sign into the Adom wiki"} → user does the hydrogen.adom.inc SSO ONCE → then EVERY thread's wikiView:"authed" is silently logged in for ~30 days (shared vault profile). Tell the user it's a one-time login that unlocks the logged-in view everywhere.`;
  }
  // public/default open — the 80% case. One line, informational.
  return ` (📖 Wiki PUBLIC view — the default, logged out, what the world sees. If the task needs the user's LOGGED-IN view (private source, drafts, owner cards, replying), reopen with wikiView:"authed" — if the user has logged in once, it's INSTANT and shared across all threads; if not, pup offers a one-time login. The USER can also flip this window themselves via its taskbar right-click menu ("🔓 Switch to logged-in view"). Ignore otherwise.)`;
}

async function fetchPageFavicon(page) {
  try {
    return await page.evaluate(async () => {
      // v1.9.43: a page can declare a PLACEHOLDER icon (example.com ships <link href="data:,">),
      // which fetches "successfully" as an empty blob and killed the whole overlay. Drop obvious
      // junk before trying, and always keep /favicon.ico as the last resort.
      const links = Array.from(document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'));
      const hrefs = links.map(l => l.href).filter(h => h && h !== 'data:,' && !/^data:,?$/.test(h));
      hrefs.push(new URL('/favicon.ico', location.origin).href);
      for (const href of hrefs) {
        try {
          const r = await fetch(href, { cache: 'force-cache' });
          if (!r.ok) continue;
          const blob = await r.blob();
          if (!blob.size) continue;
          const b64 = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result.split(',')[1]); fr.onerror = rej; fr.readAsDataURL(blob); });
          return { b64, mime: blob.type || '', href };
        } catch (e) {}
      }
      return null;
    });
  } catch (e) { return null; }
}
const _appOverlayCache = new Map(); // sessionId -> composited b64
async function applyAppOverlay(session, sessionId) {
  if (process.platform !== 'win32' || _identitySupported !== true) return false;
  try {
    // v1.9.0: reflect the ACTIVE tab (John: favicons are more useful than counts — show what
    // the user is looking at). Falls back to session.page.
    const activeTab = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];
    const activePage = (activeTab && activeTab.page) || session.page;
    let src = session._appIconB64 ? { b64: session._appIconB64, mime: session._appIconMime || 'image/png' } : null;
    if (!src) src = await fetchPageFavicon(activePage);
    if (!src || !src.b64) {
      // v1.9.43: RETRY once shortly after. The old code gave up silently on the first miss, so a
      // favicon that simply had not loaded yet meant NO overlay for the life of the window. That
      // is why some pup icons had overlays and others did not: it was a race, not a rule.
      if (!session._faviconRetried) {
        session._faviconRetried = true;
        setTimeout(() => { applyAppOverlay(session, sessionId).catch(() => {}); }, 2500);
      }
      console.log(`[overlay] "${sessionId}" no favicon on the active tab (retry ${session._faviconRetried ? 'queued' : 'done'})`);
      session._appOverlay = false;
      return false;
    }
    session._faviconRetried = false;
    // v1.9.16 (John's idea, and the right one): the favicon KEEPS the overlay slot — we only
    // recolour the PLATE it sits on. Signed in to the Adom wiki -> green plate; everything else
    // -> the normal dark plate. You still see which site you're on AND the wiki login state, and
    // at 16px the plate is a far bigger colour target than any glyph tucked in the base icon.
    // v1.9.19: plate is ALWAYS the neutral dark one again. Login state moved to the CATEGORY ICON
    // (monochrome, per the brand icon law), so tinting this plate would be a second, redundant and
    // off-law colour signal. The favicon keeps this slot untouched, which is the whole point of it.
    const plateAuthed = false;
    const plateFill = '#0b2325';
    const cacheKey = sessionId + ':' + (src.href || 'explicit') + ':' + (plateAuthed ? 'auth' : 'plain');
    let overlay = _appOverlayCache.get(cacheKey);
    if (!overlay) {
      let sharpMod = null;
      try { sharpMod = require('sharp'); } catch (e) { sharpMod = null; }
      const isIco = /x-icon|vnd\.microsoft\.icon/.test(src.mime) || (src.href || '').endsWith('.ico');
      if (isIco && !plateAuthed) {
        overlay = src.b64; // AD accepts ico base64 directly; no compositing possible (sharp can't read ico)
      } else if (!sharpMod) {
        overlay = src.b64;
      } else {
        try {
          // .ico can't be decoded by sharp — when we NEED the green plate, fall back to drawing
          // the plate alone rather than losing the signal entirely.
          let iconPng = null;
          if (!isIco) {
            try { iconPng = await sharpMod(Buffer.from(src.b64, 'base64')).resize(22, 22, { fit: 'inside' }).png().toBuffer(); } catch (e) { iconPng = null; }
          }
          const plate = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="0" y="0" width="32" height="32" rx="8" fill="${plateFill}" fill-opacity="${plateAuthed ? '1' : '0.92'}"/></svg>`);
          let img = sharpMod(plate).resize(32, 32);
          if (iconPng) img = img.composite([{ input: iconPng, gravity: 'centre' }]);
          overlay = (await img.png().toBuffer()).toString('base64');
        } catch (e) { return false; }
      }
      _appOverlayCache.set(cacheKey, overlay);
    }
    const tabs = (session.tabs || []).length;
    const nm = session._appName ? `${session._appName} — ` : '';
    await chrome.adCommand('desktop_taskbar', {
      titleContains: `(session: ${sessionId}`,
      overlay: { icon: overlay, tooltip: `Adom Pup — ${nm}${plateAuthed ? 'Adom wiki: SIGNED IN · ' : ''}${tabs} tab${tabs === 1 ? '' : 's'}${tabs > 1 ? ' (icon = active tab)' : ''}` },
    }, { timeoutMs: 5000 });
    session._appOverlay = true;
    return true;
  } catch (e) { return false; }
}
function appIconReprimand(url, hadFavicon) {
  if (hadFavicon) return '';
  const appish = /localhost|127\.0\.0\.1|\.adom\.cloud\/proxy\//i.test(String(url || ''));
  if (!appish) return '';
  return ' 🎨 APP ICON MISSING: this looks like an Adom app, but the page serves NO favicon, so its pup window cannot wear the app\'s icon (and its webview tab shows a blank icon too). The adom-ui-design standard requires every app to serve a favicon (<link rel="icon">, monochrome WHITE mark, clean 24x24 SVG — see the adom-ui-design + app-creator skills). Add one and reopen, or pass appIconB64/appIconUrl on browser_open_window meanwhile.';
}

// v1.9.9: on-screen CAPTION for the taskbar wiki toggle (John's ask: "why don't you show an AD
// screen caption saying 'switching to logged in view of wiki for [email protected]'"). The switch
// relaunches the browser under the other cookie jar and takes several seconds, so WITHOUT this a
// click felt like nothing happened at all. Same `id` on every call so the start caption is
// REPLACED by the result caption instead of stacking. Best-effort; never throws.
async function wikiCaption(text, expiresInMs) {
  try {
    // v1.9.11: AD ≥1.9.144 exposes desktop_caption on the LOOPBACK direct API (the path bridges
    // use). Before that it was relay-only — ~90 inline verbs matched only their BARE names, and
    // the CLI strips the `desktop_` prefix client-side, which is exactly why a CLI test "worked"
    // while pup's identical call silently no-op'd. Both spellings are accepted now.
    // NOTE the arg is `expiresInMs` (NOT `duration`) — default 30000, max 600000.
    // This replaced the in-page DOM toast: with a real OS surface available, pup should not be
    // injecting elements into the user's page.
    const r = await chrome.adCommand('desktop_caption', {
      text, expiresInMs, id: 'pup-wiki-view', position: 'bottom', size: 'medium',
    }, { timeoutMs: 4000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r || ''));
    if (!r || (r.success === false) || /not a built-in desktop verb|unknown/i.test(raw || '')) {
      console.log(`[wiki] desktop_caption FAILED (AD <1.9.144?) — ${(raw || 'no response').slice(0, 120)}`);
    }
  } catch (e) {}
}
// v1.9.7: reusable OS FOREGROUND raise, extracted verbatim from the proven browser_raise_os_window
// path, so the wiki view-toggle callback can actually POP the relaunched window to the user's
// screen. A jump-list click is a genuine USER action, so a real SetForegroundWindow raise is
// warranted — the earlier CDP-only maximize left the window BEHIND whatever the user was looking
// at, which is why John "never saw the switch work." CDP place+maximize → blur → bringToFront →
// clear WS_EX_NOACTIVATE + SetForegroundWindow (via the detached/browser PID, with a WMI child
// fallback for launcher→browser PID relaunches). Best-effort throughout; never throws.
async function osRaiseSessionWindow(session, sessionId) {
  try {
    try {
      const cdp = await session.page.target().createCDPSession();
      const { windowId } = await cdp.send('Browser.getWindowForTarget');
      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: 1280, height: 800, windowState: 'normal' } });
      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
    } catch (e) {}
    try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}
    try { await session.page.bringToFront(); } catch (e) {}
    session._foreground = true;
    if (process.platform !== 'win32') return;
    // v1.9.66 — RAISE BY HWND. The CDP-only path above (setWindowBounds + bringToFront) happens to
    // surface a CHROME window, but it does NOT raise an EDGE one: bringToFront activates the TAB
    // inside the window, and setWindowBounds repositions without changing z-order, so a window pup
    // deliberately parked at the BOTTOM of the z-order stays buried. Measured on ConfRoomROG (which
    // is pinned to Edge because Edge was cached as its verified default): raise returned ok:true,
    // the log said "raise GRANTED", and the screen did not change — the user's "I click and no
    // window comes up".
    // The old PID fallback was removed for a good reason (a detached launch's process() is null, and
    // _detachedPid raised the LAUNCHER's window, i.e. the WRONG one). The fix is to name the window
    // exactly: resolve THIS session's hwnd by its "(session: <id>)" title, then have AD foreground
    // that specific hwnd. Precise, browser-agnostic, and no PID guessing.
    try {
      const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 }));
      const hwnd = fw && fw.best && fw.best.hwnd;
      if (hwnd) {
        await chrome.adCommand('desktop_bring_to_front', { hwnd }, { timeoutMs: 6000 });
        console.log(`[foreground] "${sessionId}" raised by hwnd=${hwnd}`);
      } else {
        console.log(`[foreground] "${sessionId}" could not resolve an hwnd to raise`);
      }
    } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }
    return;
    // v1.9.30: resolve the PID EXACTLY like the proven browser_raise_os_window handler does:
    // browser.process() ONLY. For a DETACHED launch (every Windows pup browser) process() is null,
    // so the PS SetForegroundWindow step is SKIPPED and the CDP place+maximize+bringToFront above
    // is what shows the window — and that path demonstrably works. The old code fell back to
    // _detachedPid, which made PS raise the LAUNCHER pid's MainWindowHandle: a different window
    // than the session's. It reported "focused" every time while the real window stayed hidden,
    // which is why the wiki toggle left John with captions and no window.
    const be = browsers.get(session.profileName);
    let pid = null;
    try { pid = (be && be.browser && be.browser.process() && be.browser.process().pid) || null; } catch (e) {}
    if (!pid) return;
    const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');
    const psContent = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices;',
      'public class WinFocus {',
      '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
      '  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',
      '  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
      '  [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
      '  [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr h, int i, int v);',
      '  public static void Raise(IntPtr h){',
      '    int ex = GetWindowLong(h, -20); SetWindowLong(h, -20, ex & ~0x08000000);',
      '    if (IsIconic(h)) ShowWindow(h, 9); ShowWindow(h, 5); SetForegroundWindow(h);',
      '  }',
      '}',
      '"@',
      `$targetPid = ${pid}`,
      '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',
      'if ($p -and $p.MainWindowHandle -ne 0) {',
      '  [WinFocus]::Raise($p.MainWindowHandle)',
      "  Write-Output 'focused'",
      '} else {',
      '  Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',
      '    $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',
      '    if ($cp -and $cp.MainWindowHandle -ne 0) {',
      '      [WinFocus]::Raise($cp.MainWindowHandle)',
      "      Write-Output 'focused'",
      '      return',
      '    }',
      '  }',
      '}',
    ].join('\r\n');
    try { fs.writeFileSync(psScript, psContent); } catch (e) { return; }
    // ASYNC PS (runPsHiddenPromise) — the sync runPsHidden blocks pup's event loop for up to 8s,
    // stalling every other request; the async variant frees the loop while the raise runs.
    try { const r = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim(); console.log(`[wiki] raised session "${sessionId}" to foreground: ${r}`); } catch (e) {}
  } catch (e) {}
}
// ── v1.9.31: TAB DRAGGED OUT of a pup window → adopt it as its OWN session ──────────────
// John dragged a tab out of a pup window: Chrome created a NEW OS window, which (being a new HWND)
// carries none of pup's identity, so it wore the bare Chrome-for-Testing icon. Worse, the dragged
// page keeps its "(session: <id>)" title suffix, so ONE session suddenly spanned TWO windows and
// AD's title-based window resolution became ambiguous.
//
// Detection is EVENT-DRIVEN, never a scan (John: "scanning every second is wasteful"). AD exposes a
// global UIA subscription — desktop_ui_watch {event:"window-opened"} — whose events land in a
// 500-deep ring we drain with desktop_ui_events {sinceSeq}. A new window announces itself; we then
// do ONE cheap CDP check (Browser.getWindowForTarget per tab) only for sessions that could be
// affected. Draining is piggybacked on existing activity (the 30s health tick + tab verbs), so
// there is no dedicated timer.
//
// Policy (John's call, option A): the dragged-out tab becomes its OWN session — its own sessionId,
// identity, CATEGORY icon and jump list. That keeps "one session = one window" true, keeps titles
// unique, and lets a dragged-out digikey tab correctly show the globe instead of inheriting the
// wiki book.
// v1.9.35: AD's /command answers in TWO shapes depending on path/version: the relay wraps the
// payload in {output:"<json string>"}, while the loopback direct API can return the payload BARE.
// pup assumed the wrapped shape, so a bare payload silently became "" and every field read as
// undefined — that is why the drag-out drain saw ZERO events while a manual call saw dozens. This
// has now bitten twice (desktop_caption, desktop_ui_events), so parse defensively, once, here.
function adPayload(r) {
  if (!r || typeof r !== 'object') return null;
  let raw = r.output;
  if (raw === undefined || raw === null) {
    // BARE payload: the response IS the payload (no output wrapper).
    return (r.data && typeof r.data === 'object') ? r.data : r;
  }
  if (typeof raw !== 'string') return (raw.data && typeof raw.data === 'object') ? raw.data : raw;
  try { const o = JSON.parse(raw); return (o && o.data && typeof o.data === 'object') ? o.data : o; }
  catch (e) { return null; }
}
let _uiWatchId = null;
let _uiWatchSeq = 0;
let _lastDragCheck = 0;
let _lastReclaim = 0;
// Windows seen unowned but not yet adopted (see the two-strike rule in reclaimUnmanagedWindows).
const _reclaimStrikes = new Map();   // v1.9.42 throttle cursor for the drag-out check
let _uiWatchSupported = null;   // null = unprobed, false = AD too old
async function ensureWindowOpenedWatch() {
  if (process.platform !== 'win32' || _uiWatchSupported === false || _uiWatchId) return;
  try {
    // v1.9.34: REUSE an existing global window-opened watch instead of adding another. Every bridge
    // restart used to subscribe again and never unsubscribe, leaking watches (seen live: w1..w4).
    // Events land in ONE shared ring drained by seq, so any existing watch serves us fine.
    try {
      const ep = adPayload(await chrome.adCommand('desktop_ui_events', { sinceSeq: 0, max: 1 }, { timeoutMs: 5000 }));
      if (ep) {
        const existing = ((ep.activeWatches) || []).find(w => w.event === 'window-opened' && w.scope === 'global');
        if (existing && existing.watchId) {
          _uiWatchId = existing.watchId; _uiWatchSupported = true;
          console.log(`[drag] reusing existing window-opened watch (${_uiWatchId}) — no new subscription`);
          return;
        }
      }
    } catch (e) {}
    const r = await chrome.adCommand('desktop_ui_watch', { event: 'window-opened' }, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
    if (_isUnknownVerb(r, raw)) { _uiWatchSupported = false; return; }
    const o = raw ? JSON.parse(raw) : {};
    _uiWatchId = ((o.data || o).watchId) || (o.watchId) || 'w1';
    _uiWatchSupported = true;
    console.log(`[drag] subscribed to window-opened (watchId=${_uiWatchId}) — dragged-out tabs adopt themselves`);
  } catch (e) {}
}
// Which OS window (CDP windowId) is this page in?
async function pageWindowId(page) {
  let cdp = null;
  try {
    cdp = await page.target().createCDPSession();
    const { windowId } = await cdp.send('Browser.getWindowForTarget');
    return windowId;
  } catch (e) { console.log(`[drag] pageWindowId FAILED: ${e.message}`); return null; }
  finally { try { if (cdp) await cdp.detach(); } catch (e) {} }
}
// Split any tab that no longer shares its session's window into its own session.
async function adoptDraggedOutTabs(sessionId) {
  const session = sessions.get(sessionId);
  if (!session || (session.tabs || []).length < 2) return 0;
  let adopted = 0;
  try {
    console.log(`[drag] checking "${sessionId}" (${session.tabs.length} tabs)`);
    const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || session.tabs[0];
    const homeWin = await pageWindowId(active.page);
    console.log(`[drag] "${sessionId}" home window (active ${active.tabId}) = ${homeWin}`);
    if (homeWin == null) return 0;
    for (const t of [...session.tabs]) {
      if (t === active) continue;
      const wid = await pageWindowId(t.page);
      console.log(`[drag] "${sessionId}" ${t.tabId} window = ${wid}${wid === homeWin ? ' (same)' : ' (DIFFERENT -> adopt)'}`);
      if (wid == null || wid === homeWin) continue;
      // This tab lives in a DIFFERENT OS window now → it was dragged out. Give it its own session.
      const newId = `${sessionId}-split${adopted + 1}-${Date.now().toString(36).slice(-4)}`;
      const idx = session.tabs.indexOf(t);
      if (idx !== -1) session.tabs.splice(idx, 1);
      if (session.activeTabId === t.tabId) session.activeTabId = session.tabs.length ? session.tabs[0].tabId : null;
      const ns = createSession(session.profileName);
      ns.owner = session.owner || null;
      ns._wikiView = session._wikiView;
      ns._wikiAuthed = session._wikiAuthed;
      ns._publicProfile = session._publicProfile;
      ns._sessionId = newId;
      attachTab(ns, newId, t.page);
      sessions.set(newId, ns);
      // Re-tag the title FIRST: every downstream operation (identity stamp, overlay, jump list)
      // resolves this window by its "(session: <id>)" suffix, and until it says the NEW id the
      // window is invisible to AD and collides with its old siblings.
      await retagSessionTitle(t.page, newId, ns.profileName);
      const be = browsers.get(session.profileName);
      if (be) be.refCount++;
      let u = null; try { u = t.page.url(); } catch (e) {}
      console.log(`[drag] "${sessionId}" tab dragged out → adopted as session "${newId}" (${(u || '').slice(0, 60)})`);
      adopted++;
      // Brand the new window: its own identity, CATEGORY icon, overlay and jump list. Retried,
      // because a freshly created window is not findable by title for a beat.
      refreshWindowChrome(ns, newId).catch(() => {});
      try { saveSessionFile(newId, ns.profileName, u); } catch (e) {}
    }
    if (adopted) {
      // The SOURCE window lost a tab: its category may have changed (mixed -> wiki) and its overlay
      // favicon may belong to the tab that just left. Force a full, retried refresh.
      session._lastCat = undefined;   // defeat the same-category short-circuit
      _appOverlayCache.delete(sessionId + ':explicit');
      // Re-assert the source window's own tag (its tab set changed under it).
      try {
        const at = (session.tabs || []).find(x => x.tabId === session.activeTabId) || session.tabs[0];
        if (at) await retagSessionTitle(at.page, sessionId, session.profileName);
      } catch (e) {}
      refreshWindowChrome(session, sessionId).catch(() => {});
    }
  } catch (e) { console.log(`[drag] adopt threw: ${e.message}`); }
  return adopted;
}
// v1.9.47 — RECLAIM UNMANAGED WINDOWS. The gap that kept John's taskbar wrong.
//
// adoptDraggedOutTabs only ever looked at tabs pup ALREADY TRACKS, so it could never see a window
// pup has no tab for. Two everyday cases produce exactly that, and both leave a permanently
// UNBRANDED taskbar button (raw "Google Chrome for Testing"), which is what a taskbar audit found:
//   1. An OPENERLESS new window. wirePopupTracking deliberately drops targets with no opener (it
//      must not await target.page() on one — that re-enters the CDP pipe and hangs newPage; see
//      v1.8.18). Correct there, but nothing ever picked those windows up afterwards. Measured: one
//      ti.com session had spawned TWO untracked windows (an stm32 search + a product page).
//   2. A window whose SESSION DIED but whose Chrome kept running — e.g. the session file was lost,
//      so pup is not even connected to that profile any more.
//
// So reclamation must work from the WINDOW side, not the tab side: enumerate every page in every
// pup profile, group by OS window, and adopt any window no session owns. Runs on a timer (out of
// band — never inside a targetcreated handler, to keep the pipe safe).
async function reclaimUnmanagedWindows() {
  let reclaimed = 0;
  const handledByRescan = new Set();
  try {
    // v1.9.51 — Part 0: a session whose CDP DISCONNECTED but whose Chrome window is still alive.
    // handleBrowserDisconnect deletes the browser entry and marks the session `_lostBrowser` with
    // ZERO tabs; nothing re-evaluated it, so it lingered as a zombie stamped ".plain" and fell back
    // to the OLD default pup icon (exactly the stale icon John flagged on the shotlog window). If its
    // Chrome is truly gone, drop the session AND unregister its AUMID so no stale taskbar identity is
    // left; if its Chrome is alive, Part B below reconnects and rescanProfile reattaches it by title.
    for (const [sid, s] of [...sessions]) {
      if (!s._lostBrowser || (s.tabs || []).length) continue;
      let port = null;
      try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, s.profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}
      const alive = port && await fetch(`http://127.0.0.1:${port}/json/version`).then(r => r && r.ok).catch(() => false);
      if (alive) continue; // Part B will reconnect + rescanProfile reattaches this same session
      console.log(`[reclaim] zombie session "${sid}" (_lostBrowser, 0 tabs, Chrome gone) — dropping it and its taskbar identity`);
      try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}
      sessions.delete(sid);
      try { deleteSessionFile(sid); } catch (e) {}
    }

    // Part B: pup profiles whose Chrome is alive but that pup is not connected to. Covers a fully
    // orphaned profile AND a session whose CDP dropped (browser entry deleted on disconnect).
    let profileDirs = [];
    try { profileDirs = fs.readdirSync(PROFILES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); } catch (e) {}
    for (const profileName of profileDirs) {
      if (browsers.has(profileName)) continue;
      let port = null;
      try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}
      if (!port) continue;
      const resp = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);
      if (!resp || !resp.ok) continue;   // stale port file, Chrome is gone — leave it to the reaper
      try {
        const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });
        browser._cdpPort = port;
        browsers.set(profileName, { browser, refCount: 0 });
        wirePopupTracking(browser, profileName);
        browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));
        console.log(`[reclaim] re-connected to profile "${profileName}" on port ${port} (Chrome outlived its CDP link)`);
        // v1.9.51: reattach by TITLE TAG. rescanProfile reuses the EXISTING "(session: <id>)" session
        // instead of minting a new "<profile>-winXXXX" (which would leave the tagged session a zombie
        // AND duplicate the window). This is what actually un-zombies shotlog and restores its icon.
        try {
          const st = await rescanProfile(browser, profileName, { adoptOrphans: true });
          handledByRescan.add(profileName);
          for (const [sid, s] of sessions) {
            if (s.profileName === profileName && !s._lostBrowser && (s.tabs || []).length) {
              s._lastCat = undefined;                 // force a real category re-evaluation
              refreshWindowChrome(s, sid).catch(() => {});
            }
          }
          if (st && (st.reattached || st.adopted)) reclaimed += (st.reattached + st.adopted);
        } catch (e) { console.log(`[reclaim] rescan of "${profileName}" threw: ${e.message}`); }
      } catch (e) { continue; }
    }

    // Part A: any page in a connected profile that no session owns.
    for (const [profileName, be] of [...browsers]) {
      if (handledByRescan.has(profileName)) continue; // rescanProfile already reconciled this one
      // v1.9.50: NEVER reclaim from a profile with a launch in flight. A launching window exists as
      // a page for a moment BEFORE its session is registered, so a sweep landing in that gap sees a
      // legitimate window as unowned and adopts it — producing TWO sessions for ONE window (caught
      // by the audit as 8 sessions / 7 taskbar buttons, with "r4d" and "r4d-wina4rd" both pointing
      // at the same window). Reclaim is a janitor: when a launch is in progress the answer is simply
      // "not yet", never "adopt it".
      if (_launchInFlight.has(profileName)) continue;
      let pages = [];
      try { pages = await be.browser.pages(); } catch (e) { continue; }
      const owned = new Set();
      for (const s of sessions.values()) for (const t of (s.tabs || [])) owned.add(t.page);
      const loose = [];
      for (const p of pages) {
        if (owned.has(p)) continue;
        let u = ''; try { u = p.url(); } catch (e) { continue; }
        // about:blank is how a window is born; devtools/chrome pages are not user content.
        if (!u || u === 'about:blank' || /^(devtools|chrome|edge):/i.test(u)) continue;
        loose.push(p);
      }
      if (!loose.length) continue;
      // Group by OS window so tabs of the SAME reclaimed window land in ONE session (not one each).
      const byWindow = new Map();
      for (const p of loose) {
        let wid = null;
        try { wid = await pageWindowId(p); } catch (e) {}
        const key = wid == null ? `nowin-${loose.indexOf(p)}` : `w${wid}`;
        if (!byWindow.has(key)) byWindow.set(key, []);
        byWindow.get(key).push(p);
      }
      for (const [key, group] of byWindow) {
        // v1.9.50: two-strike confirmation. Even outside a tracked launch there are brief windows
        // where a page is real but not yet owned (a tab being attached, a session being rebuilt).
        // Adopting on first sight turns any such blink into a duplicate session that can never be
        // reconciled. Require the SAME window to look unowned on two consecutive sweeps; anything
        // transient resolves itself in between and is never touched.
        const strikeKey = `${profileName}:${key}`;
        const strikes = (_reclaimStrikes.get(strikeKey) || 0) + 1;
        if (strikes < 2) {
          _reclaimStrikes.set(strikeKey, strikes);
          console.log(`[reclaim] "${profileName}" window ${key} looks unowned (strike ${strikes}/2) — waiting one more sweep before adopting`);
          continue;
        }
        _reclaimStrikes.delete(strikeKey);
        const newId = `${profileName}-win${Math.random().toString(36).slice(2, 6)}`;
        const ns = createSession(profileName);
        ns._sessionId = newId;
        ns.owner = 'reclaimed';
        for (const p of group) attachTab(ns, newId, p);
        sessions.set(newId, ns);
        be.refCount++;
        let u = null; try { u = group[0].url(); } catch (e) {}
        // Title FIRST — every downstream brand step resolves the window by its "(session: <id>)".
        try { await retagSessionTitle(group[0], newId, profileName); } catch (e) {}
        console.log(`[reclaim] UNMANAGED window on "${profileName}" (${group.length} tab(s)) adopted as "${newId}" — ${(u || '').slice(0, 60)}`);
        refreshWindowChrome(ns, newId).catch(() => {});
        try { saveSessionFile(newId, profileName, u); } catch (e) {}
        reclaimed++;
      }
    }
  } catch (e) { console.log(`[reclaim] threw: ${e.message}`); }
  if (reclaimed) console.log(`[reclaim] adopted ${reclaimed} previously-unbranded window(s)`);
  return reclaimed;
}
// Drain the window-opened ring; a new window means SOME session may have lost a tab. Cheap: one
// drain call, then the CDP check only for multi-tab sessions.
async function drainWindowOpenedEvents() {
  if (process.platform !== 'win32' || _uiWatchSupported === false) return;
  await ensureWindowOpenedWatch();
  if (!_uiWatchId) { console.log('[drag] drain skipped: no watchId'); return; }
  try {
    const data = adPayload(await chrome.adCommand('desktop_ui_events', { sinceSeq: _uiWatchSeq, max: 200 }, { timeoutMs: 6000 }));
    if (!data) { console.log('[drag] drain: unparseable response'); return; }
    const evs = data.events || [];
    if (typeof data.nextSeq === 'number') _uiWatchSeq = data.nextSeq;
    // v1.9.32: do NOT filter these events by window NAME. A window fires window-opened at CREATION,
    // before its title exists, so the dragged-out Chrome window arrived with an EMPTY name and the
    // old `/Chrome|Chromium/` filter threw away the one event that mattered (verified live: the
    // needed event was seq 32 with name ""). Any window-opened is enough of a hint; the follow-up
    // check is cheap (one getWindowForTarget per tab, multi-tab sessions only) and idempotent.
    if (!evs.length) return;
    // v1.9.42: THROTTLE + PRE-FILTER. A busy desktop fires window-opened constantly (tooltips,
    // menus, task switching): 1360 events across 10 drains in ~a minute, measured live. Running the
    // per-tab CDP check on every drain turned an "event-driven" design into exactly the wasteful
    // polling it was meant to avoid. Two cheap guards, in order:
    //   1. Nothing multi-tab? A single-tab window cannot be split, so there is nothing to detect.
    //   2. At most one check per THROTTLE_MS, no matter how many events arrive.
    // v1.9.47: a window-opened event may also mean an UNMANAGED window appeared (openerless popup),
    // which the splittable check below cannot see — it only looks at tabs we already track.
    const THROTTLE_RECLAIM = 4000;
    if (Date.now() - _lastReclaim > THROTTLE_RECLAIM) {
      _lastReclaim = Date.now();
      reclaimUnmanagedWindows().catch(() => {});
    }
    const splittable = [...sessions].filter(([, sess]) => (sess.tabs || []).length >= 2);
    if (!splittable.length) return;
    const THROTTLE_MS = 1500;
    const now = Date.now();
    if (now - _lastDragCheck < THROTTLE_MS) return;
    _lastDragCheck = now;
    console.log(`[drag] ${evs.length} event(s) -> checking ${splittable.length} multi-tab session(s)`);
    for (const [sid] of splittable) { await adoptDraggedOutTabs(sid); }
  } catch (e) {}
}
// v1.9.40: RE-TAG a page's title suffix to a NEW sessionId. An adopted (dragged-out) tab kept the
// title of the session it came FROM, so AD could never resolve the new window by
// "(session: <newId>)" — its refresh silently failed (bare Chrome icon, stale/none overlay) and,
// worse, several windows all answered to the SAME "(session: hybrid)" string, so title-based
// operations landed on an arbitrary window (that is how a shotlog favicon ended up on a window that
// was not shotlog). Strips ANY existing pup suffix, installs the new one, and re-arms the observer.
async function retagSessionTitle(page, sessionId, profileName) {
  const suffix = profileName && profileName !== sessionId
    ? ` (session: ${sessionId} | profile: ${profileName})`
    : ` (session: ${sessionId})`;
  try {
    await page.evaluate((sfx) => {
      // v1.9.43: strip EVERY "(session: ...)" occurrence, not just a trailing one. The old session's
      // observer had already re-appended its suffix, so a trailing-only strip missed and we stacked
      // two: "BMV080 (session: thr) (session: thr-split1-mkh7)". The window then still answered to
      // the PARENT's suffix, collided with it, and its own branding landed unreliably.
      const strip = (t) => String(t || '').replace(/\s*\(session:[^)]*\)/g, '').trim();
      try { if (window.__pupTitleObs) { window.__pupTitleObs.disconnect(); window.__pupTitleObs = null; } } catch (e) {}
      document.title = strip(document.title) + sfx;
      const obs = new MutationObserver(() => {
        try { if (!document.title.endsWith(sfx)) document.title = strip(document.title) + sfx; } catch (e) {}
      });
      obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });
      window.__pupTitleObs = obs;
    }, suffix);
    return true;
  } catch (e) { return false; }
}
// v1.9.36: refresh EVERYTHING a window's taskbar presence shows (identity icon, favicon overlay,
// jump list) and RETRY until the shell can actually see the window. John: "you can't be lazy about
// those overlay icons, always make sure they are as up to date as you can get them." A brand-new
// window (a drag-out, a relaunch) is not findable by title for a beat, so a single fire-and-forget
// refresh silently no-ops and leaves a STALE overlay: after a drag-out the remaining window kept
// showing the dragged site's favicon even though that tab was gone.
async function refreshWindowChrome(session, sessionId, tries = 5) {
  if (process.platform !== 'win32' || !session) return false;
  for (let i = 0; i < tries; i++) {
    let ok = false;
    try { ok = await brandPupWindow(sessionId); } catch (e) {}
    if (ok) {
      try { await updateTabCountBadge(session, sessionId); } catch (e) {}
      try { await updateWikiJumplist(session, sessionId); } catch (e) {}
      try { await updateThumbnailTooltip(session, sessionId); } catch (e) {}
      return true;
    }
    await new Promise(r => setTimeout(r, 600 * (i + 1)));
  }
  console.log(`[chrome] "${sessionId}" could not refresh taskbar presence (window never became findable)`);
  return false;
}
// Brand a pup window: full identity on capable AD, overlay badge fallback on older AD.
async function brandPupWindow(sessionId) {
  const stamped = await stampPupIdentity(sessionId);
  if (stamped) return true;
  // v1.9.52: if identity IS supported, a false here is a transient "not yet" (window not findable,
  // or category still pending) — NOT a reason to paint the legacy badge, which would fight the
  // identity path. Only fall back to the badge on AD builds without identity support.
  if (_identitySupported === true) return false;
  return badgePupTaskbar(sessionId);
}
async function badgePupTaskbar(sessionId) {
  if (process.platform !== 'win32') return false;
  const icon = pupBadgeB64();
  if (!icon) { console.log('[badge] no icon asset — skipping'); return false; }
  // v1.8.58: CHECK the result and LOG failures. The old fire-and-forget swallowed everything —
  // when AD rejected/failed the call (e.g. window title not yet resolvable), the badge just
  // silently never appeared and every later attempt was debounced away.
  try {
    const r = await chrome.adCommand('desktop_taskbar', {
      titleContains: `(session: ${sessionId}`,
      overlay: { icon, tooltip: 'Adom pup — driven from the cloud' },
    }, { timeoutMs: 6000 });
    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r));
    const failed = (r && r.error) || (raw && /"error"\s*:\s*"[^"]/.test(raw) && !/Taskbar updated/i.test(raw));
    if (failed) { console.log(`[badge] "${sessionId}" FAILED: ${(r && r.error) || raw.slice(0, 160)}`); return false; }
    console.log(`[badge] "${sessionId}" applied`);
    return true;
  } catch (e) { console.log(`[badge] "${sessionId}" threw: ${e.message}`); return false; }
}

// NOTE: taskbar badge/overlay is an AD-CORE capability (desktop_taskbar, AD ≥1.8.152) — the
// nbrowser_* extension uses it and AD does the cross-process work natively. pup must NOT
// reimplement it (an in-bridge ITaskbarList3::SetOverlayIcon is in-process-only and fails on
// the browser's out-of-process window). A pup-distinct badge (white Adom leaves) is an AD
// feature request on desktop_taskbar; pup will call that verb via direct-API once it exists.

// v1.8.26: THE real "background by default" enforcement, done RIGHT. A freshly-launched
// --start-maximized browser grabs foreground; osSendWindowToBack only lowers z-order and
// SetForegroundWindow(userWindow) is BLOCKED by Windows' foreground lock — so the earlier
// attempts left pup on top, stealing focus. The reliable primitive: minimize the pup
// window (ShowWindow SW_MINIMIZE) — Windows then AUTOMATICALLY hands foreground to the
// user's previous window, no lock to fight — then re-show it WITHOUT activating
// (SW_SHOWNOACTIVATE) so it still composites for screenshots, and drop it to z-bottom.
// We only ever touch OUR OWN window, which is always allowed. SELF-VERIFYING: returns
// 'backgrounded' | 'still-foreground' | 'no-window' | 'error' by re-reading the
// foreground window after, so callers (and tests) know for CERTAIN whether it worked.
// cdpPort (the browser's CDP debug port) resolves the real browser via its LISTENING
// process — reliable across the launcher→browser pid relaunch that Edge/Chrome do.
function osBackgroundWindowByPid(pid, cdpPort, restoreHwnd, respectUser) {
  if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error';
  try {
    const port = Number(cdpPort) || 0;
    const restore = (restoreHwnd && /^-?\d+$/.test(String(restoreHwnd))) ? String(restoreHwnd) : '0';
    const scriptPath = path.join(require('os').tmpdir(), `_adom_bgpid_${pid || port}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices; using System.Text;',
      'public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }',
      'public class WinBgP {',
      '  public delegate bool EnumProc(IntPtr h, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, StringBuilder s, int m);',
      '  public struct RECT { public int L; public int T; public int R; public int B; }',
      '  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);',
      '  [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);',
      '  [DllImport("kernel32.dll")] public static extern uint GetTickCount();',
      '  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',
      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
      '  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);',
      '  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
      '  [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);',
      '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
      '  [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
      // Find the REAL browser window for a set of pids: the largest VISIBLE top-level
      // Chrome_WidgetWin_1 window. MainWindowHandle returns a hidden helper on some boxes
      // (that was the "park silently fails, window stuck off-screen" bug) — enumerate instead.
      '  public static IntPtr BestWindow(int[] pids) {',
      '    IntPtr best = IntPtr.Zero; long bestArea = -1;',
      '    EnumWindows(delegate(IntPtr h, IntPtr l) {',
      '      if (!IsWindowVisible(h)) return true;',
      '      uint wp; GetWindowThreadProcessId(h, out wp);',
      '      bool m = false; foreach (int p in pids) { if ((uint)p == wp) { m = true; break; } }',
      '      if (!m) return true;',
      '      StringBuilder sb = new StringBuilder(256); GetClassName(h, sb, 256);',
      '      if (sb.ToString() != "Chrome_WidgetWin_1") return true;',
      '      RECT r; GetWindowRect(h, out r); long a = (long)(r.R - r.L) * (r.B - r.T);',
      '      if (a > bestArea) { bestArea = a; best = h; }',
      '      return true;',
      '    }, IntPtr.Zero);',
      '    return best;',
      '  }',
      '}',
      '"@',
      `$targetPid = ${Number(pid) || 0}`,
      `$cdpPort = ${port}`,
      `$restore = [IntPtr]${restore}`,
      '$BOTTOM = [IntPtr]1',                 // HWND_BOTTOM
      '$SWP = 0x10',                          // SWP_NOACTIVATE (allow move+size)
      '$scrW = [WinBgP]::GetSystemMetrics(0)', // SM_CXSCREEN (primary monitor)
      '$scrH = [WinBgP]::GetSystemMetrics(1)', // SM_CYSCREEN
      '$pidList = New-Object System.Collections.ArrayList',
      'if ($targetPid -ne 0) { [void]$pidList.Add([int]$targetPid) }',
      // The process LISTENING on the CDP port is unambiguously the real browser.
      'if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$pidList.Add([int]$own) } } catch {} }',
      // Include child chrome/msedge pids — the real top-level window may belong to a child.
      '$anchors = @($pidList.ToArray())',
      'foreach ($tp in $anchors) { Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { [void]$pidList.Add([int]$_.ProcessId) } }',
      // Resolve the ONE real browser window (largest visible Chrome_WidgetWin_1), not a helper.
      '$pup = [WinBgP]::BestWindow([int[]]@($pidList.ToArray()))',
      'if ($pup -eq [IntPtr]::Zero) { Write-Output "no-window"; exit }',
      // respectUser (v1.8.49): used ONLY by LATE re-parks (post-resize etc.), never the launch
      // passes. Rationale: after startup, Chrome does NOT self-raise — so if the pup window is
      // foreground later, the USER took it (clicked its taskbar button). Leave it exactly as they
      // have it. NO input-timing heuristics: GetLastInputInfo measures input ANYWHERE on the
      // system, so "recent input" false-fires whenever the user is typing while a window opens —
      // that bug shipped in 1.8.44-48 and mis-shoved fresh windows into a small centered state.
      `$respectUser = ${respectUser ? 1 : 0}`,
      'if ($respectUser -eq 1) {',
      '  $fg0 = [WinBgP]::GetForegroundWindow()',
      '  if ($fg0 -eq $pup) { Write-Output "user-foreground"; exit }',
      '}',
      // ORDER MATTERS (v1.8.46): the browser grabbed the foreground on launch while it's still
      // OFF-SCREEN (invisible). Hand focus back to the user FIRST — deactivating the pup window
      // while it is off-screen — and ONLY THEN move it on-screen at z-bottom. If we moved it
      // on-screen first, it would flash visible on TOP for a moment (an active window stays
      // topmost until it is deactivated) = the "I saw the window open" pop. Deactivate, then park.
      // SetForegroundWindow is BLOCKED by the foreground lock, so use the AttachThreadInput trick:
      // attach the (current-foreground = pup) window's input thread to ours, SetForegroundWindow
      // the user's window, detach. This is what makes background-by-default NOT steal keystrokes.
      '$fg = [WinBgP]::GetForegroundWindow()',
      'if ($fg -eq $pup -and $restore -ne [IntPtr]::Zero) {',
      '  $procId = [uint32]0',
      '  $pupTid = [WinBgP]::GetWindowThreadProcessId($fg, [ref]$procId)',
      '  $myTid = [WinBgP]::GetCurrentThreadId()',
      '  [WinBgP]::AttachThreadInput($pupTid, $myTid, $true) | Out-Null',
      '  [WinBgP]::SetForegroundWindow($restore) | Out-Null',
      '  [WinBgP]::BringWindowToTop($restore) | Out-Null',
      '  [WinBgP]::AttachThreadInput($pupTid, $myTid, $false) | Out-Null',
      '}',
      // NOW pup is deactivated (still off-screen). Move it on-screen sized to the monitor at the
      // BOTTOM of the z-order (SWP_NOACTIVATE): fully behind the user's windows (invisible, never
      // covering) but reachable via its taskbar button. Because it is no longer the active
      // window, dropping it to HWND_BOTTOM keeps it hidden — no pop.
      '[WinBgP]::SetWindowPos($pup, $BOTTOM, 0, 0, $scrW, $scrH, $SWP) | Out-Null',
      // Re-verify: the user's window (not pup) should be foreground.
      '$fg2 = [WinBgP]::GetForegroundWindow()',
      'if ($fg2 -eq $pup) { Write-Output "still-foreground" } else { Write-Output "backgrounded" }',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = runPsHidden(scriptPath).trim();
    return out || 'error';
  } catch (e) { console.error(`[bg] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }
}

// v1.8.50: THE background algorithm — ONE event-driven watchdog, no blind timers.
// Replaces the "park + re-park at 300/700/1200ms" kludge. What it does, in order:
//   1. WAIT for the real browser window to EXIST (poll EnumWindows @50ms, up to ~4s — reacting
//      to the event "window created", not guessing a delay). The window is OFF-SCREEN, invisible.
//   2. The moment it exists: hand keyboard focus back to the user's window (AttachThreadInput
//      dance — only if Chrome actually took the foreground), THEN move it on-screen at z-bottom
//      (deactivate-first order = no visible pop). ONE park.
//   3. STABILITY WATCH: for up to 2s, check the foreground @50ms. If Chrome re-grabs it
//      (startup self-raise), hand back + re-park INSTANTLY (50ms reaction, not a 400ms timer
//      gap). Exit as soon as the foreground has been non-pup for 500ms straight — typically
//      well under a second after first paint.
//   4. EXIT. After this process ends, NOTHING ever re-parks the window. A user click on the
//      taskbar button brings it up full-size and pup never touches it again. No input-timing
//      heuristics anywhere (GetLastInputInfo false-fired while the user typed — removed).
// Returns: 'backgrounded' | 'still-foreground' | 'no-window' | 'error' (self-verified).
async function osBackgroundWatchdog(pid, cdpPort, restoreHwnd) {
  if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error';
  try {
    const port = Number(cdpPort) || 0;
    const restore = (restoreHwnd && /^-?\d+$/.test(String(restoreHwnd))) ? String(restoreHwnd) : '0';
    const scriptPath = path.join(require('os').tmpdir(), `_adom_bgwd_${pid || port}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices; using System.Text;',
      'public class WinWd {',
      '  public delegate bool EnumProc(IntPtr h, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, StringBuilder s, int m);',
      '  public struct RECT { public int L; public int T; public int R; public int B; }',
      '  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);',
      '  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',
      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
      '  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);',
      '  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
      '  [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);',
      '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
      '  [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
      '  public static IntPtr BestWindow(int[] pids) {',
      '    IntPtr best = IntPtr.Zero; long bestArea = -1;',
      '    EnumWindows(delegate(IntPtr h, IntPtr l) {',
      '      if (!IsWindowVisible(h)) return true;',
      '      uint wp; GetWindowThreadProcessId(h, out wp);',
      '      bool m = false; foreach (int p in pids) { if ((uint)p == wp) { m = true; break; } }',
      '      if (!m) return true;',
      '      StringBuilder sb = new StringBuilder(256); GetClassName(h, sb, 256);',
      '      if (sb.ToString() != "Chrome_WidgetWin_1") return true;',
      '      RECT r; GetWindowRect(h, out r); long a = (long)(r.R - r.L) * (r.B - r.T);',
      '      if (a > bestArea) { bestArea = a; best = h; }',
      '      return true;',
      '    }, IntPtr.Zero);',
      '    return best;',
      '  }',
      '}',
      '"@',
      `$targetPid = ${Number(pid) || 0}`,
      `$cdpPort = ${port}`,
      `$restore = [IntPtr]${restore}`,
      '$BOTTOM = [IntPtr]1',
      '$SWP = 0x10',                            // SWP_NOACTIVATE
      '$scrW = [WinWd]::GetSystemMetrics(0)',
      '$scrH = [WinWd]::GetSystemMetrics(1)',
      '$myTid = [WinWd]::GetCurrentThreadId()',
      // Focus handback: only acts when pup IS the foreground (Chrome stole it); else a no-op.
      'function HandBack([IntPtr]$pup) {',
      '  $fg = [WinWd]::GetForegroundWindow()',
      '  if ($fg -eq $pup -and $restore -ne [IntPtr]::Zero) {',
      '    $procId = [uint32]0',
      '    $pupTid = [WinWd]::GetWindowThreadProcessId($fg, [ref]$procId)',
      '    [WinWd]::AttachThreadInput($pupTid, $myTid, $true) | Out-Null',
      '    [WinWd]::SetForegroundWindow($restore) | Out-Null',
      '    [WinWd]::BringWindowToTop($restore) | Out-Null',
      '    [WinWd]::AttachThreadInput($pupTid, $myTid, $false) | Out-Null',
      '  }',
      '}',
      // 1. Wait for the window to EXIST (the event, not a guessed delay). BestWindow (cheap
      //    EnumWindows) runs @50ms; the EXPENSIVE pid-list resolution (Get-NetTCPConnection +
      //    Get-CimInstance) refreshes only every ~500ms — running it every tick overran the
      //    process budget when several windows opened at once (the wiki3-4 'error').
      'function ResolvePids {',
      '  $l = New-Object System.Collections.ArrayList',
      '  if ($targetPid -ne 0) { [void]$l.Add([int]$targetPid) }',
      '  if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$l.Add([int]$own) } } catch {} }',
      '  $anchors = @($l.ToArray())',
      '  foreach ($tp in $anchors) { Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { [void]$l.Add([int]$_.ProcessId) } }',
      '  return ,[int[]]@($l.ToArray())',
      '}',
      '$pup = [IntPtr]::Zero',
      '$pids = ResolvePids',
      '$deadline = [Environment]::TickCount + 4000',
      '$nextResolve = [Environment]::TickCount + 500',
      'while ([Environment]::TickCount -lt $deadline) {',
      '  $pup = [WinWd]::BestWindow($pids)',
      '  if ($pup -ne [IntPtr]::Zero) { break }',
      '  if ([Environment]::TickCount -ge $nextResolve) { $pids = ResolvePids; $nextResolve = [Environment]::TickCount + 500 }',
      '  Start-Sleep -Milliseconds 50',
      '}',
      'if ($pup -eq [IntPtr]::Zero) { Write-Output "no-window"; exit }',
      // 2. Park ONCE — with a user-click escape hatch that is now UNAMBIGUOUS: because
      //    --no-startup-window means Chrome cannot raise itself, "pup window is foreground"
      //    can only mean THE USER clicked its taskbar button during the launch gap (while the
      //    window was still at its off-screen coords, where activating it shows nothing). In
      //    that case: place it on-screen at the TOP full-size, keep the user's focus on it,
      //    and report user-foreground. Otherwise: single SetWindowPos to on-screen z-bottom.
      //    Park, verify, EXIT — nothing runs after this either way.
      '$fg1 = [WinWd]::GetForegroundWindow()',
      'if ($fg1 -eq $pup) {',
      '  [WinWd]::SetWindowPos($pup, [IntPtr]0, 0, 0, $scrW, $scrH, 0x40) | Out-Null', // HWND_TOP, SWP_SHOWWINDOW
      '  Write-Output "user-foreground"; exit',
      '}',
      'HandBack $pup',
      '[WinWd]::SetWindowPos($pup, $BOTTOM, 0, 0, $scrW, $scrH, $SWP) | Out-Null',
      // 3. Self-verify and exit. Nothing runs after this — the user owns the window now.
      '$fg2 = [WinWd]::GetForegroundWindow()',
      'if ($fg2 -eq $pup) { Write-Output "still-foreground" } else { Write-Output "backgrounded" }',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = await runPsHiddenPromise(scriptPath, { timeout: 9000 });
    return out || 'error';
  } catch (e) { console.error(`[bgwd] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }
}

// v1.8.47: THE background primitive, rebuilt around MINIMIZE (replaces the on-screen-z-bottom
// park + its re-park retry loop, which fought the user's clicks). Why minimize is correct:
//  • Minimizing a foreground window hands the foreground back to the user's window AUTOMATICALLY
//    (no foreground-lock fight, no AttachThreadInput) — R4 (no focus steal) for free.
//  • We set the window's RESTORE rectangle on-screen (SetWindowPlacement rcNormalPosition), so a
//    taskbar click restores it centered + visible — and it STAYS, because there is NO re-park loop
//    to yank it back. This is exactly the user↔AI handoff John demanded.
//  • Screenshots keep working (R3) because Chrome launches with occlusion detection disabled
//    (--disable-features=CalculateNativeWinOcclusion + --disable-backgrounding-occluded-windows),
//    so a minimized window still composites frames for Page.captureScreenshot.
//  • No visible pop (R1): the window launched OFF-SCREEN, so minimizing it is never seen.
// respectUser (on the safety re-passes): if the window is NOT minimized and a real user input
// happened in the last ~1.5s, the USER restored it → return "user-foreground", do NOT re-minimize.
// Returns "minimized" | "user-foreground" | "still-foreground" | "no-window" | "error".
function osMinimizeToBackground(pid, cdpPort, respectUser) {
  if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error';
  try {
    const port = Number(cdpPort) || 0;
    const scriptPath = path.join(require('os').tmpdir(), `_adom_min_${pid || port}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices; using System.Text;',
      'public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }',
      'public class WinMin {',
      '  public delegate bool EnumProc(IntPtr h, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, StringBuilder s, int m);',
      '  public struct RECT { public int L; public int T; public int R; public int B; }',
      '  public struct POINT { public int X; public int Y; }',
      '  [StructLayout(LayoutKind.Sequential)] public struct WP { public int length; public int flags; public int showCmd; public POINT ptMin; public POINT ptMax; public RECT rc; }',
      '  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);',
      '  [DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WP p);',
      '  [DllImport("user32.dll")] public static extern bool SetWindowPlacement(IntPtr h, ref WP p);',
      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
      '  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);',
      '  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
      '  [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);',
      '  [DllImport("kernel32.dll")] public static extern uint GetTickCount();',
      '  public static IntPtr BestWindow(int[] pids) {',
      '    IntPtr best = IntPtr.Zero; long bestArea = -1;',
      '    EnumWindows(delegate(IntPtr h, IntPtr l) {',
      '      if (!IsWindowVisible(h) && !IsIconic(h)) return true;',
      '      uint wp; GetWindowThreadProcessId(h, out wp);',
      '      bool m = false; foreach (int p in pids) { if ((uint)p == wp) { m = true; break; } }',
      '      if (!m) return true;',
      '      StringBuilder sb = new StringBuilder(256); GetClassName(h, sb, 256);',
      '      if (sb.ToString() != "Chrome_WidgetWin_1") return true;',
      '      RECT r; GetWindowRect(h, out r); long a = (long)(r.R - r.L) * (r.B - r.T);',
      '      if (a > bestArea) { bestArea = a; best = h; }',
      '      return true;',
      '    }, IntPtr.Zero);',
      '    return best;',
      '  }',
      '}',
      '"@',
      `$targetPid = ${Number(pid) || 0}`,
      `$cdpPort = ${port}`,
      '$scrW = [WinMin]::GetSystemMetrics(0)',
      '$scrH = [WinMin]::GetSystemMetrics(1)',
      '$pidList = New-Object System.Collections.ArrayList',
      'if ($targetPid -ne 0) { [void]$pidList.Add([int]$targetPid) }',
      'if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$pidList.Add([int]$own) } } catch {} }',
      '$anchors = @($pidList.ToArray())',
      'foreach ($tp in $anchors) { Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { [void]$pidList.Add([int]$_.ProcessId) } }',
      '$pup = [WinMin]::BestWindow([int[]]@($pidList.ToArray()))',
      'if ($pup -eq [IntPtr]::Zero) { Write-Output "no-window"; exit }',
      // Already minimized = already parked. Never re-touch it (that would fight a user who is
      // about to/just restored it). This is what makes the design not yank the window.
      'if ([WinMin]::IsIconic($pup)) { Write-Output "minimized"; exit }',
      `$respectUser = ${respectUser ? 1 : 0}`,
      'if ($respectUser -eq 1) {',
      '  $fg0 = [WinMin]::GetForegroundWindow()',
      '  if ($fg0 -eq $pup) {',
      '    $lii = New-Object LASTINPUTINFO',
      '    $lii.cbSize = [uint32][System.Runtime.InteropServices.Marshal]::SizeOf($lii)',
      '    [WinMin]::GetLastInputInfo([ref]$lii) | Out-Null',
      '    $idle = [WinMin]::GetTickCount() - $lii.dwTime',
      '    if ($idle -lt 1500) { Write-Output "user-foreground"; exit }',
      '  }',
      '}',
      // Minimize WITHOUT activating (SW_SHOWMINNOACTIVE=7) and set the RESTORE rect on-screen
      // (centered ~80%), so a taskbar click restores it visible. PowerShell can only mutate a
      // struct field on the top-level variable, so build RECT separately and assign the whole rc.
      '$wp = New-Object -TypeName "WinMin+WP"',
      '$wp.length = [int][System.Runtime.InteropServices.Marshal]::SizeOf($wp)',
      '[WinMin]::GetWindowPlacement($pup, [ref]$wp) | Out-Null',
      '$vw = [int]($scrW * 0.8); $vh = [int]($scrH * 0.8); $vx = [int](($scrW - $vw)/2); $vy = [int](($scrH - $vh)/2)',
      '$rc = New-Object -TypeName "WinMin+RECT"',
      '$rc.L = $vx; $rc.T = $vy; $rc.R = $vx + $vw; $rc.B = $vy + $vh',
      '$wp.showCmd = 7',
      '$wp.rc = $rc',
      '[WinMin]::SetWindowPlacement($pup, [ref]$wp) | Out-Null',
      '$fg2 = [WinMin]::GetForegroundWindow()',
      'if ($fg2 -eq $pup) { Write-Output "still-foreground" } else { Write-Output "minimized" }',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = runPsHidden(scriptPath).trim();
    return out || 'error';
  } catch (e) { console.error(`[min] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }
}

// v1.8.53: send ONE hwnd to the bottom of the z-order. This is the ONLY Win32 call left with
// no direct API (CDP has no z-order; AD has no send-to-back verb yet — issue filed). Minimal
// PS: one P/Invoke signature, no window-finding, no pid guessing, hwnd comes from AD's native
// desktop_find_window. Everything else in the park is CDP/AD direct.
function osBottomUnlessForeground(hwnd, force) {
  if (process.platform !== 'win32' || !hwnd) return 'error';
  try {
    const scriptPath = path.join(require('os').tmpdir(), `_adom_zbot_${hwnd}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices;',
      'public class ZBot {',
      '  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',
      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
      '}',
      '"@',
      // THE OS truth: GetForegroundWindow. (document.hasFocus() via CDP LIES for CDP-created
      // windows — it reports internal focus even when the OS never foregrounded them, which
      // false-fired 'user-foreground' on every open in the first 1.8.53 build.) With
      // --no-startup-window, OS-foreground == the user clicked; then do NOT bottom it.
      `$h = [IntPtr]${Number(hwnd)}`,
      `$force = ${force ? 1 : 0}`,
      'if ($force -eq 0 -and [ZBot]::GetForegroundWindow() -eq $h) { Write-Output "foreground"; exit }',
      '[ZBot]::SetWindowPos($h, [IntPtr]1, 0, 0, 0, 0, 0x13) | Out-Null', // HWND_BOTTOM; SWP_NOSIZE|NOMOVE|NOACTIVATE
      'Write-Output "bottomed"',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = runPsHidden(scriptPath, { timeout: 5000 }).trim();
    return out || 'error';
  } catch (e) { return 'error'; }
}

// v1.8.53: THE park — direct APIs end to end (John: "why powershell? that's heavy. why aren't
// you just calling direct api's?"). No EnumWindows, no pid guessing, no focus handback (with
// --no-startup-window nothing can steal focus), no wait loop (called AFTER newPage resolved, so
// the window exists — the await IS the event). Steps:
//   1. Escape hatch: CDP `document.hasFocus()` — can ONLY be true if the USER clicked the
//      taskbar during the launch gap (Chrome can't self-focus anymore) → move on-screen, leave
//      them on it, report 'user-foreground'.
//   2. Z-bottom while still OFF-SCREEN (invisible): hwnd via AD desktop_find_window (native,
//      title-suffix match), then osBottomUnlessForeground (the one residual Win32 call,
//      which ALSO reads GetForegroundWindow — the only honest user-click signal).
//   3. Re-check focus (user may have clicked mid-park) → if so, on-screen + AD bring_to_front.
//   4. CDP Browser.setWindowBounds → on-screen full-size. CDP addresses the EXACT window by
//      windowId, so the catch-2 failure mode (parked the wrong window) is impossible.
//   5. Verify POSITION via CDP getWindowBounds (catch-2's park "verified" by focus alone and
//      lied) → 'backgrounded' | 'park-failed'.
async function parkSessionWindowDirect(page, sessionId) {
  if (process.platform !== 'win32') return null;
  let cdp = null;
  try {
    cdp = await page.target().createCDPSession();
    const { windowId } = await cdp.send('Browser.getWindowForTarget');
    // v1.8.66: PLACEMENT DOCTRINE (three shipped placements raised the window — CDP maximize
    // = SW_MAXIMIZE raises; AD set_window_bounds raises despite restore:false; the on-screen
    // small stage was visible. ALL of it John caught live):
    //  1. Geometry via CDP plain bounds ONLY (left/top/width/height, normal state; CSS px from
    //     the page itself). NEVER windowState:'maximized', NEVER AD set_window_bounds.
    //  2. Size is measured, not guessed: screen.availWidth/Height with retries (the one probe
    //     failure shipped 1600×900 once — retry beats fallback).
    //  3. TRUST NOTHING: after placement, RE-BOTTOM and VERIFY THE Z-ORDER via AD
    //     desktop_list_windows. If pup's window cannot be confirmed below the user's windows,
    //     move it back OFF-SCREEN and report park-failed. A window that might be covering the
    //     user NEVER stays on-screen.
    const cssScreen = async () => {
      for (let i = 0; i < 4; i++) {
        try {
          const s = await page.evaluate(() => ({ w: screen.availWidth, h: screen.availHeight }));
          if (s && s.w > 400 && s.h > 300) return s;
        } catch (e) {}
        await new Promise(r2 => setTimeout(r2, 300));
      }
      return null;
    };
    const placeOnScreen = async () => {
      const s = await cssScreen();
      if (!s) return false;
      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: Math.round(s.w), height: Math.round(s.h) } });
      return true;
    };
    const moveOffScreen = async () => {
      try {
        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: -32000, top: -32000, width: 1280, height: 800 } });
      } catch (e) {}
    };
    // Z-order truth: our window's z must be strictly below at least one non-pup window, and it
    // must not be topmost. Returns true only on CONFIRMED not-on-top.
    const zConfirmedBelow = async (targetHwnd) => {
      try {
        const r = await chrome.adCommand('desktop_list_windows', {}, { timeoutMs: 5000 });
        const raw = r && (typeof r.output === 'string' && r.output ? JSON.parse(r.output) : r.output || r);
        const ws = ((raw && (raw.data || raw)).windows) || [];
        if (!ws.length) return false;
        const mine = ws.find(w => Number(w.hwnd) === Number(targetHwnd));
        if (!mine) return false;
        if (Number(mine.z) === 0) return false;
        // at least one NON-pup window above ours
        return ws.some(w => Number(w.z) < Number(mine.z) && !/\(session: /.test(w.title || ''));
      } catch (e) { return false; }
    };
    // hwnd via AD (native) — the title suffix "(session: <id>" is set by the welcome page.
    // v1.8.59 HARD RULES, learned the expensive way:
    //  1. The window is NEVER moved on-screen until z-bottom is CONFIRMED ('bottomed'). A
    //     lagged hwnd lookup once fell through and placed an un-bottomed window ON TOP of the
    //     user's work (the nxp.com pop). Invisible off-screen ('park-failed' + self-heal)
    //     beats covering the user's screen.
    //  2. The launch park bottoms UNCONDITIONALLY (force=true) — there is NO user-click escape
    //     hatch here. "Pup window is foreground at park time" has too many non-user causes:
    //     Windows re-assigns the foreground to a brand-new window when the previous foreground
    //     window was just destroyed (e.g. a bridge-update restart killing pup windows — that
    //     forced another thread's fresh window onto John's screen), plus creation blips. The
    //     false-positive cost is a forced window on the user's screen (the #1 forbidden
    //     behavior); the false-negative cost is a user who clicked during the ~2s launch gap
    //     clicking ONCE more after the park — after which the window stays, since nothing
    //     ever re-parks. Cheap. Do NOT reintroduce a foreground check here.
    // v1.8.61: z-bottom via AD-CORE `desktop_set_window_state {state:'bottom'}` (shipped in AD
    // 1.9.114 per pup's feature request) — native, background-drive, resolves the window by
    // title itself, and returns {sentToBack, wasForeground}. This deletes pup's LAST PowerShell
    // from the open path; window management is now 100% direct API. Legacy fallback (older AD
    // that rejects state:'bottom'): the old find_window + osBottomUnlessForeground PS pair.
    const adBottom = async () => {
      try {
        // v1.8.63: force:true (AD ≥1.9.115) — bottom even if the window is currently foreground.
        // At launch, foreground is NOT a user signal (post-restart reassignment), so pup always
        // forces. On AD 1.9.114 the arg is ignored (skip-on-foreground) and the wasForeground
        // fallback below covers it; that fallback auto-obsoletes as desktops update.
        const r = await chrome.adCommand('desktop_set_window_state', { titleContains: `(session: ${sessionId}`, state: 'bottom', force: true }, { timeoutMs: 5000 });
        if (r && r.success === false) return { ok: false, err: r.error || 'error' };
        const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));
        const o = raw ? JSON.parse(raw) : {};
        const data = o.data || o;
        return { ok: !!data.sentToBack, wasForeground: !!data.wasForeground, hwnd: data.hwnd || null, err: data.sentToBack ? null : 'not sent to back' };
      } catch (e) { return { ok: false, err: e.message }; }
    };
    let bottomed = false, lastErr = 'error', parkHwnd = null;
    for (let attempt = 0; attempt < 8; attempt++) {
      const z = await adBottom();
      if (z.ok) { bottomed = true; parkHwnd = z.hwnd; break; }
      lastErr = z.err || 'error';
      // Two cases need the legacy PS path (BOTH auto-obsolete as desktops update to AD ≥1.9.115,
      // whose force:true is honored above — delete this block once the fleet is there):
      //  (a) older AD that rejects state:'bottom' entirely;
      //  (b) AD 1.9.114 exactly: has state:'bottom' but ignores force and SKIPS a foreground
      //      window (wasForeground:true, sentToBack:false). At LAUNCH, foreground is NOT a user
      //      signal (post-restart foreground reassignment — the v61-3 case), so force via PS.
      if (z.wasForeground || /invalid|unknown|must be one of|maximize\|minimize/i.test(lastErr)) {
        let hwnd = null;
        try {
          const r = await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 4000 });
          const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r);
          hwnd = (o && ((o.best && o.best.hwnd) || o.hwnd)) || null;
        } catch (e) {}
        if (hwnd && osBottomUnlessForeground(hwnd, true) === 'bottomed') { bottomed = true; parkHwnd = hwnd; break; }
      }
      await new Promise(r2 => setTimeout(r2, 300));
    }
    if (!bottomed) {
      console.log(`[park] "${sessionId}": z-bottom NOT confirmed (${lastErr}) — leaving off-screen (no pop), self-heal will retry`);
      return 'park-failed';
    }
    // On-screen full-size WITHOUT activation — AD MoveWindow preserves z-order (we're at bottom).
    if (!parkHwnd) {
      try {
        const r = await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 4000 });
        const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r);
        parkHwnd = (o && ((o.best && o.best.hwnd) || o.hwnd)) || null;
      } catch (e) {}
    }
    if (!parkHwnd) { console.log(`[park] "${sessionId}": no hwnd for placement — leaving off-screen, self-heal will retry`); return 'park-failed'; }
    // Place on-screen full-size (CDP plain bounds — the only move that doesn't raise), then
    // RE-BOTTOM (trust nothing) and CONFIRM the z-order. Unconfirmable → back off-screen.
    const placed = await placeOnScreen();
    if (!placed) {
      console.log(`[park] "${sessionId}": screen-size probe failed — leaving off-screen, self-heal will retry`);
      return 'park-failed';
    }
    let zOk = false;
    for (let i = 0; i < 2 && !zOk; i++) {
      await adBottom(); // re-assert after the move, every time
      zOk = await zConfirmedBelow(parkHwnd);
    }
    if (!zOk) {
      console.log(`[park] "${sessionId}": Z-ORDER UNCONFIRMED after placement — moving back OFF-SCREEN (never leave a window that might cover the user)`);
      await moveOffScreen();
      return 'park-failed';
    }
    // Verify POSITION too (a stuck-off-screen window must not read as parked).
    let verified = false;
    try {
      const gb = await cdp.send('Browser.getWindowBounds', { windowId });
      const b = gb && gb.bounds;
      verified = !!(b && b.left > -1000 && b.top > -1000);
    } catch (e) {}
    return verified ? 'backgrounded' : 'park-failed';
  } catch (e) {
    console.error(`[park] ${sessionId}: ${e.message}`);
    return 'error';
  } finally {
    if (cdp) { try { await cdp.detach(); } catch (e) {} }
  }
}

// v1.8.67: re-assert z-bottom after a navigation settles. The OPEN's first cross-origin nav
// (file:// welcome → https real site) swaps renderer processes and Chromium re-shows the window
// at first paint — RAISING it — and heavy sites paint AFTER the park's z-confirm passed (the w3
// batch sat on top with honest 'backgrounded' verdicts; a later same-window navigate does NOT
// raise — tested). force:false so AD skips a window the user is actively holding foreground.
async function reassertBottomAfterNav(sessionId) {
  if (process.platform !== 'win32' || !sessionId) return;
  try {
    await chrome.adCommand('desktop_set_window_state', { titleContains: `(session: ${sessionId}`, state: 'bottom', force: false }, { timeoutMs: 5000 });
  } catch (e) {}
}

// v1.8.57: SELF-HEAL a stranded window. A window created under a build whose park failed (or
// any future placement bug) sits at its off-screen launch coords forever: its taskbar button
// "activates" an invisible window and the badge is gone — exactly what happened to the
// adom-shotlog app's long-lived shared window. pup now detects and repairs this at every
// natural touchpoint (bridge-restart reattach, open_tab, switch_tab): cheap CDP bounds check;
// only if actually off-screen, re-run the standard park (which respects a user-foreground
// window) and re-apply the badge. Healthy windows are never touched.
async function healOffscreenWindow(session, sessionId) {
  if (process.platform !== 'win32') return null;
  try {
    const tab = session && session.tabs && session.tabs[0];
    const page = tab && tab.page;
    if (!page) return null;
    const cdp = await page.target().createCDPSession();
    let b = null;
    try {
      const { windowId } = await cdp.send('Browser.getWindowForTarget');
      const gb = await cdp.send('Browser.getWindowBounds', { windowId });
      b = gb && gb.bounds;
    } finally { try { await cdp.detach(); } catch (e) {} }
    if (!b || (b.left > -1000 && b.top > -1000)) return null; // on-screen — healthy, hands off (v1.8.64: catches the -1310 staging spot too)
    console.log(`[heal] "${sessionId}" window stranded off-screen (${b.left},${b.top}) — re-parking + re-badging`);
    const r = await parkSessionWindowDirect(page, sessionId);
    brandPupWindow(sessionId).catch(() => {});
    return r;
  } catch (e) { return null; }
}

// v1.8.45: TRULY clear a window's taskbar attention (the orange/pink Win11 "flash" tint).
// FLASHW_STOP only stops the PULSE — the tint persists until the window is ACTIVATED. So we
// clear it the only way Windows allows: activate the window. To avoid a visible pop, we move
// it OFF-SCREEN first, activate it there (invisible → clears the tint), move it back on-screen
// at z-bottom, then hand focus straight back to whatever the user was in (AttachThreadInput).
// Net effect: the flash clears, nothing pops, focus is never stolen. Returns "cleared" /
// "cleared-still-foreground" / "already-foreground" (user already had it) / "no-window".
function osClearFlashByActivate(pid, cdpPort) {
  if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error';
  try {
    const port = Number(cdpPort) || 0;
    const scriptPath = path.join(require('os').tmpdir(), `_adom_clrflash_${pid || port}.ps1`);
    const ps = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices; using System.Text;',
      'public class WinClr {',
      '  public delegate bool EnumProc(IntPtr h, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);',
      '  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, StringBuilder s, int m);',
      '  public struct RECT { public int L; public int T; public int R; public int B; }',
      '  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);',
      '  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',
      '  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
      '  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);',
      '  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
      '  [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);',
      '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
      '  [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
      '  [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
      '  public static IntPtr BestWindow(int[] pids) {',
      '    IntPtr best = IntPtr.Zero; long bestArea = -1;',
      '    EnumWindows(delegate(IntPtr h, IntPtr l) {',
      '      if (!IsWindowVisible(h)) return true;',
      '      uint wp; GetWindowThreadProcessId(h, out wp);',
      '      bool m = false; foreach (int p in pids) { if ((uint)p == wp) { m = true; break; } }',
      '      if (!m) return true;',
      '      StringBuilder sb = new StringBuilder(256); GetClassName(h, sb, 256);',
      '      if (sb.ToString() != "Chrome_WidgetWin_1") return true;',
      '      RECT r; GetWindowRect(h, out r); long a = (long)(r.R - r.L) * (r.B - r.T);',
      '      if (a > bestArea) { bestArea = a; best = h; }',
      '      return true;',
      '    }, IntPtr.Zero);',
      '    return best;',
      '  }',
      '}',
      '"@',
      `$targetPid = ${Number(pid) || 0}`,
      `$cdpPort = ${port}`,
      '$BOTTOM = [IntPtr]1',                  // HWND_BOTTOM
      '$NOACT = 0x10',                         // SWP_NOACTIVATE
      '$scrW = [WinClr]::GetSystemMetrics(0)',
      '$scrH = [WinClr]::GetSystemMetrics(1)',
      '$pidList = New-Object System.Collections.ArrayList',
      'if ($targetPid -ne 0) { [void]$pidList.Add([int]$targetPid) }',
      'if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$pidList.Add([int]$own) } } catch {} }',
      '$anchors = @($pidList.ToArray())',
      'foreach ($tp in $anchors) { Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { [void]$pidList.Add([int]$_.ProcessId) } }',
      '$pup = [WinClr]::BestWindow([int[]]@($pidList.ToArray()))',
      'if ($pup -eq [IntPtr]::Zero) { Write-Output "no-window"; exit }',
      '$myTid = [WinClr]::GetCurrentThreadId()',
      // Snapshot the user's current foreground so we can restore it. If it's ALREADY the pup
      // window, the user has it up (flash is moot) — do nothing.
      '$userFg = [WinClr]::GetForegroundWindow()',
      'if ($userFg -eq $pup) { Write-Output "already-foreground"; exit }',
      // 1. Move the window OFF-SCREEN (invisible), without activating.
      '[WinClr]::SetWindowPos($pup, $BOTTOM, -32000, -32000, $scrW, $scrH, $NOACT) | Out-Null',
      // 2. Activate the pup window off-screen → clears the taskbar attention tint. Bypass the
      //    foreground lock by attaching our input thread to the current foreground's thread.
      '$uProc = [uint32]0',
      '$uTid = [WinClr]::GetWindowThreadProcessId($userFg, [ref]$uProc)',
      '[WinClr]::AttachThreadInput($uTid, $myTid, $true) | Out-Null',
      '[WinClr]::SetForegroundWindow($pup) | Out-Null',
      '[WinClr]::BringWindowToTop($pup) | Out-Null',
      '[WinClr]::AttachThreadInput($uTid, $myTid, $false) | Out-Null',
      'Start-Sleep -Milliseconds 60',
      // 3. Park back on-screen at z-bottom (invisible behind the user's windows).
      '[WinClr]::SetWindowPos($pup, $BOTTOM, 0, 0, $scrW, $scrH, $NOACT) | Out-Null',
      // 4. Hand focus back to the user's window.
      '$pProc = [uint32]0',
      '$pTid = [WinClr]::GetWindowThreadProcessId($pup, [ref]$pProc)',
      '[WinClr]::AttachThreadInput($pTid, $myTid, $true) | Out-Null',
      '[WinClr]::SetForegroundWindow($userFg) | Out-Null',
      '[WinClr]::BringWindowToTop($userFg) | Out-Null',
      '[WinClr]::AttachThreadInput($pTid, $myTid, $false) | Out-Null',
      '$fg2 = [WinClr]::GetForegroundWindow()',
      '$stillPup = $false',
      'foreach($h in $handles){ if($h -eq $fg2){ $stillPup = $true } }',
      'if ($stillPup) { Write-Output "cleared-still-foreground" } else { Write-Output "cleared" }',
    ].join('\r\n');
    fs.writeFileSync(scriptPath, ps);
    const out = runPsHidden(scriptPath).trim();
    return out || 'error';
  } catch (e) { console.error(`[clrflash] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }
}

// NOTE: localhost-URL reprimand (container-vs-desktop) is handled by AD CORE, which
// appends a container-topology hint to a failed response whose URL is localhost. pup
// doesn't duplicate it — and because AD keys on the connection FAILURE, Hydrogen
// Desktop's working localhost URLs load fine and never trip it. (Enriching AD's hint
// with actionable alternatives is an adom/adom-desktop request.)

// v1.8.23: REAL taskbar flash (FlashWindowEx) for a session's window — the orange
// nudge that tells the user "the agent touched this window" WITHOUT stealing focus.
// v1.8.24: a FINITE 4-blink pulse then stop (see Flash() below) — NOT a blink-forever
// nag. Skips if the window is already foreground. Fire-and-forget (async exec) so it
// never adds latency to the driving verb.
// v1.8.43: pup's taskbar flash routes through AD-CORE desktop_flash_window (native +
// reliable, ONE mechanism for flash AND clear) instead of pup's own FlashWindowEx — which
// duplicated an AD capability, and whose fire-and-forget FLASHW_STOP wouldn't actually
// clear the Win11 attention tint. Resolves the window by its "(session: <id>)" title suffix.
// {stop:true} clears it; otherwise a finite 4-count flash. Returns AD's real result so
// callers can report truthfully (no more cleared:true lies).
async function flashViaAD(sessionId, opts) {
  opts = opts || {};
  if (process.platform !== 'win32' || !sessionId) return null;
  const args = opts.stop
    ? { titleContains: `(session: ${sessionId}`, stop: true }
    : { titleContains: `(session: ${sessionId}`, mode: 'count', count: 4 };
  try { return await chrome.adCommand('desktop_flash_window', args, { timeoutMs: 6000 }); }
  catch (e) { return null; }
}

// v1.8.23: verbs that MUTATE a session's visible state → auto-flash the window so the
// user knows the agent changed it. evaluate counts UNLESS {readOnly:true}.
const MUTATING_VERBS = new Set(['browser_open_window', 'browser_open_tab', 'browser_navigate', 'browser_reload', 'browser_back', 'browser_forward', 'browser_switch_tab', 'browser_click', 'browser_type', 'browser_press_key', 'browser_input_dispatch', 'browser_eval', 'browser_evaluate']);

// Debounced auto-flash: one flash per window per ~5s burst of verbs, re-armed after
// quiet. {silent:true} on the verb OR session._flashSilent (session default) suppresses.
// v1.8.71 (the Aditya/Google-auth incident): pup is the ANONYMOUS SANDBOXED browser — a user
// signing into a real account inside a pup window either gets lost on the next fresh window or
// (on a branded-browser fallback) triggers Chrome's profile-creation/consolidation machinery.
// When a caller opens a known auth/login URL, steer them to the right surface in the hint.
const AUTH_URL_RE = /accounts\.google\.com|login\.microsoftonline\.com|login\.live\.com|\.okta\.com|\.auth0\.com|github\.com\/login|signin\.aws\.amazon\.com|appleid\.apple\.com|login\.salesforce\.com|id\.atlassian\.com|auth\.openai\.com|\/o\/oauth2\/|\/oauth2?\/(v2\/)?auth(orize)?/i;
function authSteerHint(url) {
  if (!url || !AUTH_URL_RE.test(String(url))) return '';
  return ' 🔑 AUTH URL DETECTED: pup is the ANONYMOUS SANDBOXED browser — its profiles are disposable, so a login done here is easily lost (next fresh window = signed out), and it must not become the home of the user\'s identity. For signing the USER into their accounts prefer: (a) abe / nbrowser_* — their REAL browser where sessions + password manager already live, or (b) AD-core desktop_open_url — a plain native OS launch of their default browser (zero automation machinery; ideal for OAuth flows that just need the user to click Approve). Continue in pup ONLY for throwaway/test accounts or when the flow explicitly needs an anonymous context.';
}
const _flashState = new Map(); // sessionId -> last flash ts
function scheduleAutoFlash(session, opts) {
  opts = opts || {};
  if (process.platform !== 'win32' || !session || !session._sessionId) return;
  if (opts.silent || session._flashSilent) return;
  const key = session._sessionId;
  const now = Date.now();
  if (now - (_flashState.get(key) || 0) < 5000) return; // coalesce the burst
  _flashState.set(key, now);
  flashViaAD(session._sessionId, {}); // fire-and-forget (async → AD desktop_flash_window)
}

// Record what the agent just did to a session, for lastAgentUpdate (browser_list_tabs /
// list_windows / status) and the "● " title marker — so the user + Session Monitor can
// see WHICH window/tab, WHAT verb, and the optional human note, WHEN.
function recordAgentUpdate(session, verb, args, tabId) {
  if (!session) return;
  const note = (args && typeof args.updateNote === 'string' && args.updateNote.trim()) ||
               (args && typeof args.note === 'string' && args.note.trim()) || null;
  const upd = { tabId: tabId || session.activeTabId || null, verb, note, ts: Date.now() };
  session._lastAgentUpdate = upd;
  if (upd.tabId) { const t = (session.tabs || []).find(x => x.tabId === upd.tabId); if (t) t.lastAgentUpdate = upd; }
}

// v1.8.37: CLEANUP NUDGE. pup never auto-closes windows (a "stale" one may belong to a
// paused thread), so windows an agent opened for a finished task otherwise pile up on the
// user's desktop forever. This returns a _hint (appended to open/status responses) reminding
// the caller to close what it's done with — but only when it's actually worth nagging: some
// window is old (≥ OLD_MIN) OR there are a lot of them (≥ MANY). Lists the oldest offenders.
function staleWindowsHint() {
  try {
    const OLD_MIN = 30, MANY = 5;
    const now = Date.now();
    const all = [];
    for (const [sid, s] of sessions) {
      all.push({ sid, ageMin: s.createdAt ? Math.round((now - s.createdAt) / 60000) : 0, owner: s.owner || null });
    }
    const old = all.filter(w => w.ageMin >= OLD_MIN);
    if (old.length === 0 && all.length < MANY) return '';
    const show = (old.length ? old : all).sort((a, b) => b.ageMin - a.ageMin).slice(0, 6)
      .map(w => `${w.sid}${w.owner ? `(owner:${w.owner})` : ''}=${w.ageMin}m`).join(', ');
    return ` 🧹 CLEANUP: ${all.length} pup window(s) open${old.length ? `, ${old.length} ≥${OLD_MIN}m old` : ''} — close the ones you're DONE with via browser_close_window so they don't pile up on the user's desktop (pup never auto-closes; that's your job when a task ends). Oldest: [${show}].`;
  } catch (e) { return ''; }
}

// Resize/position a session's window via CDP (works for detached+connected). Setting
// explicit bounds also un-maximizes the window (--start-maximized is otherwise the
// default). x/y/width/height in screen pixels; any subset is honored.
async function setSessionWindowBounds(session, { x, y, width, height } = {}) {
  try {
    const cdp = await session.page.target().createCDPSession();
    const { windowId } = await cdp.send('Browser.getWindowForTarget');
    // Must leave maximized/minimized state before setting a size, or Chrome ignores w/h.
    await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
    const bounds = { windowState: 'normal' };
    if (Number.isFinite(width))  bounds.width  = Math.round(width);
    if (Number.isFinite(height)) bounds.height = Math.round(height);
    if (Number.isFinite(x))      bounds.left   = Math.round(x);
    if (Number.isFinite(y))      bounds.top    = Math.round(y);
    await cdp.send('Browser.setWindowBounds', { windowId, bounds });
    try { await cdp.detach(); } catch {}
    return true;
  } catch (e) { console.error(`[bounds] setSessionWindowBounds failed: ${e.message}`); return false; }
}

async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false, foreground = false } = {}) {
  // Check if browser already running for this profile.
  // isConnected() can be stale after laptop sleep/wake — the WebSocket hasn't
  // noticed the disconnect yet. Validate with a real CDP call (pages()) with
  // a short timeout to catch zombie browser handles.
  const existing = browsers.get(profileName);
  if (existing && existing.browser.isConnected()) {
    try {
      await Promise.race([
        existing.browser.pages(),
        new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2000)),
      ]);
      return existing.browser; // Really alive
    } catch (e) {
      console.log(`Cached browser for "${profileName}" failed health check (${e.message}) — discarding`);
    }
  }
  // Clean up stale entry
  if (existing) {
    try { await existing.browser.close(); } catch (e) {}
    browsers.delete(profileName);
  }

  // v1.9.4: CONCURRENT-LAUNCH DEDUP — see `_launchInFlight` declaration. If a launch for this
  // profile is already running, JOIN it (return its Promise) rather than spawning a competing
  // Chrome onto the same userDataDir (which mutually lock-kills and wedges the bridge). The
  // reuse-of-a-live-browser fast path above already returned; only genuine (re)launches reach here.
  if (_launchInFlight.has(profileName)) {
    console.log(`[launch] profile "${profileName}" is already launching — joining the in-flight launch (no second Chrome)`);
    return await _launchInFlight.get(profileName);
  }
  let _resolveLaunch, _rejectLaunch;
  const _launchPromise = new Promise((res, rej) => { _resolveLaunch = res; _rejectLaunch = rej; });
  _launchPromise.catch(() => {}); // mark handled — joiners still observe rejection via their own await
  _launchInFlight.set(profileName, _launchPromise);
  try {

  // Prefer an ALREADY-INSTALLED browser (Chrome → Edge; every Windows box has Edge)
  // so the common case needs NO ~150 MB Chrome-for-Testing download. Falls back to a
  // cached CfT. A fresh --user-data-dir (below) keeps it CLEAN — it never touches the
  // user's real tabs/logins (driving the real browser is the Adom extension's job).
  //
  // launchCandidates() returns the FULL ordered fallback list ([pinned default] →
  // Chrome → Edge → cached CfT). The launch loop below tries each in turn and
  // SPAWN-VERIFIES it — a broken/partial top pick (e.g. a corrupt Chrome install)
  // falls through to the next real browser instead of hard-failing the open. The
  // browser that actually launches is cached as the default (chrome.markVerified).
  const candidates = chrome.launchCandidates();
  if (candidates.length) {
    console.log(`[launch] ${candidates.length} browser candidate(s): ` + candidates.map(c => `${c.kind}(${c.source})`).join(' → '));
  } else {
    console.log('[launch] no system browser or cached CfT detected — falling back to puppeteer.executablePath()');
  }

  const launchOpts = {
    headless: false,
    defaultViewport: null,
    // executablePath is set PER-CANDIDATE in the spawn-verified launch loop below.
    args: [
      '--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security',
      '--start-maximized', '--remote-debugging-port=0',
      // Suppress "Sign in to Chromium" popup and first-run dialogs
      '--no-first-run', '--no-default-browser-check', '--disable-default-apps',
      '--disable-sync', '--disable-background-networking',
      '--disable-infobars',
      // Kill the "Chrome is being controlled by automated test software" infobar.
      // It's triggered by the PRESENCE of the --enable-automation switch (Chrome keys on
      // presence, not value) — so ignoreDefaultArgs strips puppeteer's default one, and we
      // must NOT pass it ourselves. The old '--enable-automation=false' actually RE-ENABLED
      // the bar. Chrome-for-Testing hides this bar natively; regular Chrome/Edge need
      // --test-type (ChromeDriver's approach), which ALSO suppresses the "unsupported
      // command-line flag" (--disable-web-security) warning bar.
      '--test-type',
      '--disable-blink-features=AutomationControlled',
      // Tab recording uses CDP Page.startScreencast piped to ffmpeg — see
      // tabRecordStartImpl below. CDP screencast is scoped to a specific
      // page target at the protocol level, so cross-tab/cross-Chrome
      // leakage is impossible. The flags below keep the page rendering at
      // full rate even when occluded, so screencast frames stream
      // continuously instead of being paint-throttled to ~0 fps:
      //   --disable-renderer-backgrounding:        keep rendering when in background tab
      //   --disable-backgrounding-occluded-windows: keep rendering when window is occluded
      //   --disable-background-timer-throttling:    keep timers/RAF firing in background
      //   --disable-features=CalculateNativeWinOcclusion:
      //                                              skip the occlusion-detection probe
      // Without these, recording a tab while the user is in another app
      // produces a 1-2 fps video.
      '--disable-renderer-backgrounding',
      '--disable-backgrounding-occluded-windows',
      '--disable-background-timer-throttling',
      '--disable-features=CalculateNativeWinOcclusion',
    ],
    ignoreDefaultArgs: ['--enable-automation'],
    // Let Chrome survive if the bridge process dies — enables reconnect on restart
    handleSIGINT: false,
    handleSIGTERM: false,
    handleSIGHUP: false,
    pipe: false,
  };

  // nativeSite mode: strip flags that break complex Google sites (GCP Console,
  // Google Workspace admin, etc.). These sites rely on background networking,
  // CORS enforcement, and Chrome sync for org/billing context.
  if (nativeSite) {
    const stripFlags = new Set(['--disable-background-networking', '--disable-sync', '--disable-web-security']);
    launchOpts.args = launchOpts.args.filter(a => !stripFlags.has(a));
    console.log(`nativeSite mode: stripped ${stripFlags.size} flags for real-site compatibility`);
  }

  let userDataDir = null;
  if (!freshProfile) {
    userDataDir = path.join(PROFILES_DIR, profileName);
    fs.mkdirSync(userDataDir, { recursive: true });
    launchOpts.userDataDir = userDataDir;
    console.log(`Browser for profile "${profileName}": persistent at ${userDataDir}`);
  } else {
    console.log(`Browser for profile "${profileName}": fresh temporary profile`);
  }

  // Try to reconnect to an existing Chrome first (e.g. after bridge restart).
  // After laptop sleep/wake, fetch() can hang indefinitely on dead sockets,
  // so we apply a 3s timeout via AbortController — failure falls through
  // to the launch path (which has its own retry + orphan cleanup).
  let browser;
  if (userDataDir) {
    const portFile = path.join(userDataDir, 'DevToolsActivePort');
    try {
      const portData = fs.readFileSync(portFile, 'utf8').trim();
      const port = parseInt(portData.split('\n')[0], 10);
      if (port && !isNaN(port)) {
        const ac = new AbortController();
        const tid = setTimeout(() => ac.abort(), 3000);
        let resp = null;
        try {
          resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });
        } catch (e) {
          // timed out, refused, or TCP stack hung after sleep — ignore
          console.log(`Reconnect probe to :${port} failed (${e.message}) — will relaunch`);
        } finally {
          clearTimeout(tid);
        }
        if (resp && resp.ok) {
          // Also gate puppeteer.connect with a timeout — it can hang too
          try {
            browser = await Promise.race([
              puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),
              new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),
            ]);
            browser._cdpPort = port;
            const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);
            if (sFiles.length > 0) browser._detachedPid = sFiles[0].pid;
            console.log(`Reconnected to existing Chrome on port ${port} for profile "${profileName}"`);
          } catch (e) {
            console.log(`puppeteer.connect to :${port} failed (${e.message}) — will relaunch`);
            browser = null;
          }
        }
      }
    } catch { /* DevToolsActivePort doesn't exist or is stale — launch fresh */ }
  }

  if (!browser) {
    // Pre-launch cleanup: kill any orphaned Chrome holding this profile's lock
    if (userDataDir) {
      const lockFile = path.join(userDataDir, 'SingletonLock');
      const lockExists = fs.existsSync(lockFile) || fs.existsSync(path.join(userDataDir, 'lockfile'));
      if (lockExists) {
        console.log(`Profile "${profileName}" has a lock file — checking for orphaned Chrome...`);
        await killBrowserProcess(null, profileName);
        // Give OS time to release the lock
        await new Promise(r => setTimeout(r, 1000));
      }

      // Pup is AI-driven, never human-driven, so Chrome's "restore last
      // session" is pure cost: it reopens stale tabs from previous runs
      // (8+ tab buildup after a few iter cycles) AND those tabs keep
      // running heavy content (3D viewers, animations) under the
      // anti-throttle flags we set, eating CPU. Wipe the session-restore
      // state before launch — the AI knows what it wants open.
      prepareCleanProfile(userDataDir);
    }

    // ─── Helper: kill any Chrome processes locking this profile dir ───
    // v1.6.2+: route through killOrphanChromesForProfile + cleanProfileLocks
    // (PowerShell-based) instead of wmic. wmic was deprecated in 2021 and
    // removed from the default PATH in recent Windows 11 updates — when
    // it's missing, the legacy code silently failed, leaving stale Chrome
    // processes holding the profile and producing the canonical "Chrome
    // process exited immediately" symptom on every retry. Caught the day
    // v1.6.1 shipped because the recovery path exercised this helper more
    // aggressively than before.
    const killChromeHoldingProfile = async () => {
      if (!userDataDir) return;
      console.log(`Killing any Chrome processes holding profile "${profileName}"...`);
      try {
        await killOrphanChromesForProfile(profileName);
      } catch (e) {
        console.log(`killChromeHoldingProfile (orphan kill): ${e.message}`);
      }
      try {
        await cleanProfileLocks(profileName);
      } catch (e) {
        console.log(`killChromeHoldingProfile (lock cleanup): ${e.message}`);
      }
      // Give OS time to release locks
      await new Promise(r => setTimeout(r, 1500));
    };

    // ─── Helper: one detached-launch attempt, returns browser or throws ───
    const tryDetachedLaunch = async (attemptNum, chromePathArg) => {
      const { spawn } = require('child_process');
      // Launch the candidate the loop is currently trying (installed Chrome/Edge
      // preferred; cached CfT fallback). Falls back to puppeteer's own path only if
      // no candidate path was resolved (the no-system-browser edge case).
      const chromePath = chromePathArg || puppeteer.executablePath();
      const debugPort = 9200 + Math.floor(Math.random() * 800);
      // v1.8.26: BACKGROUND-BY-DEFAULT, done right. Chrome/Edge aggressively grab the
      // foreground on launch (and re-grab it whenever shown), so minimizing/z-ordering
      // AFTER the fact loses the race and the window flashes over the user's work. The
      // reliable fix: launch it OFF-SCREEN (far past any monitor). CDP still renders it,
      // so browser_screenshot/eval/record all work, but the user NEVER sees it pop up and
      // it never covers their active window. foreground:true launches maximized on-screen
      // as before. browser_raise_os_window moves it on-screen when the user asks to watch.
      // v1.8.51: `--no-startup-window` is THE fix for the focus-steal war. Windows only grants
      // a process the right to take the foreground at launch. With this flag the browser
      // process starts with NO window at all — the window is created LATER via CDP (newPage),
      // by which time Chrome is a background process and the OS itself DENIES it the
      // foreground. So: no steal is possible, no watchdog/timer war, and a user's taskbar
      // click can never be fought (nothing is running to fight it). The off-screen position
      // args stay as belt-and-suspenders for the brief moment before the park.
      // v1.8.95: SUPPRESS the browser sign-in / "set up a work profile" prompt. I was WRONG
      // that CfT lacks this machinery — it shows the SAME "Sign in to Chromium? Set up a work
      // profile" dialog that poisoned Aditya (John proved it live 2026-07-19). These flags kill
      // it at launch so NO user ever sees it: browser account sign-in off, sync off, the sign-in
      // promos/intercepts off. The wiki auth we DO want is a plain site cookie — unaffected.
      // This PREVENTS the poisoning at the source; the auth-URL steering hint stays as backup.
      const noBrowserIdentityFlags = [
        '--allow-browser-signin=false',
        '--disable-sync',
        '--disable-features=SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker',
        '--disable-signin-promo',
      ];
      const chromeArgs = [
        ...launchOpts.args.filter(a => a !== '--remote-debugging-port=0' && (foreground || a !== '--start-maximized')),
        `--remote-debugging-port=${debugPort}`,
        `--user-data-dir=${userDataDir}`,
        ...noBrowserIdentityFlags,
        ...(foreground ? [] : ['--no-startup-window', '--window-position=-32000,-32000', '--window-size=1280,800']),
      ];
      console.log(`Chrome launch attempt ${attemptNum} for profile "${profileName}" on CDP port ${debugPort}`);
      // v1.8.34: snapshot the user's foreground window right before launch (background only)
      // so we can hand keyboard focus back to it after the browser grabs it.
      const priorForeground = foreground ? null : osGetForegroundWindow();
      const child = spawn(chromePath, chromeArgs, {
        detached: true,
        stdio: 'ignore',
        windowsHide: false,
      });
      child.unref();

      // Wait for the browser to start and CDP to be ready (30s — cold start can be slow).
      // IMPORTANT (Edge + some Chrome channels): the process we spawned is often just a
      // LAUNCHER that relaunches the real browser onto a DIFFERENT PID and then exits. So
      // we probe CDP FIRST every tick, and on launcher-death we give CDP one last chance
      // before declaring failure — otherwise a perfectly-good Edge reads as "exited
      // immediately". (The old order killed Edge launches dead.)
      let connected = false;
      let childDied = false;
      for (let i = 0; i < 60; i++) {
        await new Promise(r => setTimeout(r, 500));
        // CDP up? Then we're connected regardless of which PID now hosts the browser.
        try {
          const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
          if (resp.ok) { connected = true; break; }
        } catch {}
        // Is the process we spawned still alive?
        let alive = true;
        try {
          if (process.platform === 'win32') {
            const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
            alive = out.includes(`"${child.pid}"`);
          } else {
            process.kill(child.pid, 0); // throws if dead
          }
        } catch { alive = false; }
        if (!alive) {
          // Launcher exited — the browser may be up on another PID. Final CDP grace probe.
          await new Promise(r => setTimeout(r, 700));
          try {
            const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
            if (resp.ok) { connected = true; break; }
          } catch {}
          childDied = true; break;
        }
      }

      if (!connected) {
        // Kill the zombie we spawned before throwing
        try {
          if (process.platform === 'win32') execSync(`taskkill /F /T /PID ${child.pid}`, { stdio: 'ignore' });
          else process.kill(child.pid, 'SIGKILL');
        } catch {}
        const reason = childDied ? 'Chrome process exited immediately' : 'CDP never became ready';
        throw new Error(`Chrome launch attempt ${attemptNum} failed: ${reason} (port ${debugPort}, PID ${child.pid})`);
      }

      const b = await puppeteer.connect({ browserURL: `http://127.0.0.1:${debugPort}`, defaultViewport: null });
      b._detachedPid = child.pid;
      b._cdpPort = debugPort;
      b._priorForeground = priorForeground; // v1.8.34: user's window to hand focus back to
      console.log(`Launched detached Chrome (PID ${child.pid}) on CDP port ${debugPort} for profile "${profileName}"`);
      // v1.8.24: ENFORCE background-by-default the instant the window exists — push it
      // behind the user's windows AND hand focus back to whatever they were in, so the
      // --start-maximized launch never steals focus. Retry a few times because Chrome/Edge
      // can re-raise itself during startup (first paint, restore prompts). Resolve the
      // window via the CDP-port owner (reliable across launcher→browser pid relaunches).
      if (!foreground && process.platform === 'win32') {
        // v1.8.53: with --no-startup-window there is NO window yet (it's created by newPage()
        // in launchSession) — so the park happens THERE, via parkSessionWindowDirect (CDP +
        // AD direct APIs, no PS window-hunting). Just mark that this fresh browser needs it.
        b._bgResult = 'parking';
        b._needsPark = true;
      }
      return b;
    };

    // ─── Spawn-verified launch: try each candidate browser in order, up to 3 ───
    // attempts each (with orphan/lock cleanup between attempts). If a browser can't
    // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next
    // candidate rather than failing the open. First one that launches wins + is cached.
    const isWinDetached = process.platform === 'win32' && userDataDir;
    const MAX_ATTEMPTS = 3;
    let lastErr = null;
    let chosen = null;
    const triedList = [];
    // No detected candidates (no system browser + no cached CfT) → let puppeteer pick
    // its own executablePath. This is the rare cold-start edge the gate usually catches.
    const launchList = candidates.length ? candidates : [{ kind: 'default', source: 'puppeteer', executablePath: null }];

    for (const cand of launchList) {
      if (cand.executablePath) launchOpts.executablePath = cand.executablePath;
      else delete launchOpts.executablePath;
      lastErr = null;
      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        try {
          if (isWinDetached) {
            browser = await tryDetachedLaunch(attempt, cand.executablePath);
          } else {
            browser = await puppeteer.launch(launchOpts);
          }
          chosen = cand;
          lastErr = null;
          break;
        } catch (err) {
          lastErr = err;
          console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} for ${cand.kind}(${cand.source}) failed: ${err.message}`);
          if (attempt >= MAX_ATTEMPTS) break;
          // Recovery before retry: kill any lingering Chrome holding the profile + clear locks
          await killChromeHoldingProfile();
        }
      }
      if (browser) break;
      triedList.push({ kind: cand.kind, source: cand.source, executablePath: cand.executablePath || null, error: lastErr && lastErr.message });
      if (launchList.length > 1) {
        console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts — trying next browser`);
        // A failed candidate (e.g. an Edge that aborted) can leave SingletonLock /
        // DevToolsActivePort in the shared userDataDir that would poison the NEXT
        // browser. Clear them before the next candidate so the fallback is clean.
        await killChromeHoldingProfile();
      }
    }

    if (browser && chosen) {
      // Record what actually launched so the open response + hints are accurate, and
      // cache it as the auto-default so we don't re-probe next time.
      browser._pupChosen = { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null };
      // v1.8.71 (#202): a BRANDED-browser launch means CfT wasn't cached — kick a background
      // CfT install (once per bridge process) so this box converges to the deterministic
      // CfT-first order by its next session. Never blocks or stalls the current open.
      if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked && process.platform === 'win32') {
        global._cftPrewarmKicked = true;
        try {
          console.log(`[prewarm] branded fallback (${chosen.kind}) — starting background CfT install so future opens use Chrome for Testing`);
          chrome.ensureChromeReady({ background: true });
        } catch (e) {}
      }
      _lastChosenBrowser = { ...browser._pupChosen, fallbackFrom: triedList.map(t => `${t.kind}(${t.source})`) };
      if (chosen.executablePath) chrome.markVerified(chosen);
      if (triedList.length) console.log(`[launch] launched ${chosen.kind}(${chosen.source}) after ${triedList.length} browser(s) failed to spawn`);
    }

    if (!browser) {
      // Every candidate failed to spawn. Branch on whether ANY browser was even
      // detectable so the error is actionable, not a dev string telling the AI to run npx.
      const triedTxt = triedList.length
        ? triedList.map(t => `${t.kind}(${t.source}): ${t.error}`).join(' | ')
        : '(no browsers were detected to try)';
      const cdet = chrome.detectChrome();
      if (!candidates.length && !cdet.installed) {
        const e = new Error('No Chrome/Edge on this machine and Chrome for Testing is not installed — pup cannot launch a browser yet.');
        e.errorCode = 'no_browser_available';
        e._hint = 'This box has no installed Chrome or Edge and no cached Chrome for Testing. Fastest fix: have the user install Chrome or Edge (Edge ships with Windows) — pup uses it instantly, no download. Or call browser_prewarm / browser_use {browser:"cft"} to fetch Chrome for Testing (~150 MB, one time), poll browser_readiness until ready:true, then retry.';
        throw e;
      }
      const e = new Error(
        `Every detected browser failed to spawn for profile "${profileName}" (${MAX_ATTEMPTS} attempts each). Tried — ${triedTxt}.`
      );
      e.errorCode = 'browser_launch_failed';
      e._hint = `pup tried every browser it found and none would launch (usually low memory, a locked/busy profile, or corrupt installs). Tried: ${triedList.map(t => `${t.kind}(${t.source})`).join(', ')}. Retry once; if it persists, pin a specific browser with browser_use {browser:"chrome"|"edge"|"cft"}, or surface the last error to the user.`;
      throw e;
    }
  }

  browsers.set(profileName, { browser, refCount: 0 });

  // v1.4.12: pre-grant the dialog-suppressing permissions before any
  // navigation happens. Done at browser-context level so every tab/origin
  // inherits — caller doesn't need to re-grant per origin. See
  // applyDefaultPermissions for rationale + opt-out via strictPermissions.
  await applyDefaultPermissions(browser, profileName, { strictPermissions });

  // Auto-track popups: window.open() / target=_blank navigation on this Chrome
  // gets attached to the session that owns the opener tab. See wirePopupTracking.
  wirePopupTracking(browser, profileName);

  // Clean up when browser is closed externally (user closed Chrome, crashed, etc.)
  // v1.5.1+: see handleBrowserDisconnect for rationale. Sessions are kept
  // in the in-memory Map + on disk so browser_rescan / health-check can
  // recover the still-alive Chrome window. Old behavior (delete everything)
  // hijacked unrelated windows on subsequent calls.
  browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));

  // v1.9.4: hand the launched browser to any concurrent joiners, then clear the in-flight slot.
  _launchInFlight.delete(profileName);
  _resolveLaunch(browser);
  return browser;
  } catch (_launchErr) {
    // v1.9.4: propagate the SAME failure to every concurrent joiner and clear the slot so a
    // later open can retry cleanly (a stuck slot would otherwise wedge all future opens).
    _launchInFlight.delete(profileName);
    _rejectLaunch(_launchErr);
    throw _launchErr;
  }
}

// v1.9.19: create a page in a BRAND NEW OS WINDOW of an existing browser. puppeteer's newPage()
// always makes a tab; only CDP Target.createTarget accepts newWindow. Page identity is resolved by
// diffing browser.pages() before/after rather than matching internal target ids, so this stays
// correct across puppeteer versions. Returns null if the window never materialises.
async function createPageInNewWindow(browser) {
  let cdp = null;
  try {
    const before = new Set(await browser.pages());
    cdp = await browser.target().createCDPSession();
    await cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true });
    for (let i = 0; i < 50; i++) {
      const fresh = (await browser.pages()).find(p => !before.has(p));
      if (fresh) return fresh;
      await new Promise(r => setTimeout(r, 100));
    }
    return null;
  } catch (e) {
    console.log(`[window] createPageInNewWindow failed: ${e.message}`);
    return null;
  } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }
}

// Launch a session as a tab in a Chrome browser
// profile: which Chrome profile to use (defaults to sessionId)
// If another session already uses that profile, this creates a NEW TAB in the same Chrome
async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false, foreground = false, silent = false } = {}) {
  const profileName = profile || sessionId;

  // If session already exists and page is still alive, just navigate
  const existing = sessions.get(sessionId);
  if (existing) {
    const browserEntry = browsers.get(existing.profileName);
    if (browserEntry && browserEntry.browser.isConnected()) {
      try {
        // Check if the page is still valid
        await existing.page.title();
        // ── v1.8.16 ownership gate ────────────────────────────────────────
        // Reusing an existing sessionId with a NEW url = navigating away a
        // window some thread was using. If both sides declared owners and
        // they differ, REFUSE (unless takeover:true) — the caller is about
        // to steal another thread's window. Same-owner / no-url reuse is the
        // legitimate "refresh my own window" pattern and passes untouched.
        let prevUrl = null; try { prevUrl = existing.page.url() || null; } catch {}
        const navigatingAway = !!url && !!prevUrl && url !== prevUrl;
        const declaredMismatch = navigatingAway && existing.owner && owner && owner !== existing.owner;
        if (declaredMismatch && !takeover) {
          const age = Math.round((Date.now() - (existing.createdAt || Date.now())) / 60000);
          const err = new Error(`Session "${sessionId}" belongs to another thread (owner "${existing.owner}", opened ${age} min ago, currently showing ${prevUrl}).`);
          err.code = 'session_owned_by_another_thread';
          err.details = { sessionId, owner: existing.owner, requestedBy: owner, currentUrl: prevUrl, ageMinutes: age };
          throw err;
        }
        // One-shot reuse metadata for the open_window handler's hint:
        // was this a benign refresh, a sanctioned takeover, or an anonymous grab?
        existing._reuseMeta = {
          reused: true,
          previousUrl: prevUrl,
          navigatedAway: navigatingAway,
          previousOwner: existing.owner || null,
          sameOwner: !!(existing.owner && owner && owner === existing.owner),
          anonymousGrab: navigatingAway && !!existing.owner && !owner,
          takeover: !!(declaredMismatch && takeover),
        };
        // Ownership follows a sanctioned takeover or fills in when unset.
        if ((takeover || !existing.owner) && owner) existing.owner = owner;
        if (url) {
          await existing.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
          existing.errors.length = 0;
        }
        activeSessionId = sessionId;
        return existing;
      } catch (e) {
        if (e && e.code === 'session_owned_by_another_thread') throw e;
        // Page was closed — fall through to create new tab
        console.log(`Session "${sessionId}" page was closed — recreating tab`);
      }
    }
    // Stale session — clean up
    sessions.delete(sessionId);
    if (existing.profileName) {
      const be = browsers.get(existing.profileName);
      if (be) be.refCount = Math.max(0, be.refCount - 1);
      // v1.6.1+: kill orphan Chromes + clean profile locks before relaunching.
      // Without this, a stale `lockfile` (Windows) or `SingletonLock` (POSIX)
      // left behind by an unclean shutdown causes the next puppeteer.launch
      // to exit immediately with "Chrome process exited immediately." The
      // sleep/wake recovery scenario hit exactly this: Chrome died during
      // sleep, lockfile remained, every relaunch attempt failed silently.
      try {
        await killOrphanChromesForProfile(existing.profileName);
        await cleanProfileLocks(existing.profileName);
      } catch (e) {
        console.log(`[launchSession] pre-relaunch cleanup failed (non-fatal): ${e.message}`);
      }
    }
  }

  // Get or launch Chrome for this profile
  const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite, foreground });

  // Prefer to REUSE the startup blank tab (every fresh Chrome opens one) instead
  // of creating a new tab and then closing the blank. The startup tab's URL
  // varies across Chrome versions: about:blank, chrome://newtab/,
  // chrome://new-tab-page/, chrome-search://local-ntp/..., so match loosely.
  const isBlank = (u) => {
    if (!u) return true;
    return u === 'about:blank'
      || u.startsWith('chrome://newtab')
      || u.startsWith('chrome://new-tab-page')
      || u.startsWith('chrome-search://local-ntp')
      || u === 'edge://newtab/';
  };

  // Snapshot existing pages and find a reusable blank one NOT already claimed
  // by another session.
  const claimedPages = new Set();
  for (const s of sessions.values()) { if (s.page) claimedPages.add(s.page); }
  const existingPages = await browser.pages();
  // v1.9.19: ONE SESSION = ONE OS WINDOW, even when sessions share a profile.
  // browser.newPage() opens a TAB in whatever window the profile already has, so two sessions on
  // the shared adom-wiki-authed jar collapsed into one window. A window's title reflects only its
  // ACTIVE tab, so AD could not resolve "the window titled (session: <id>)" for the background
  // one -- every per-window operation keyed on that title silently failed (icon stamp, taskbar
  // overlay, flash, jump list), which is why the login icon would not repaint after a view toggle.
  // Fix: when another live session already owns a window on this profile, create the page in its
  // OWN window via CDP Target.createTarget {newWindow:true}. Same cookie jar, separate window.
  const profileAlreadyInUse = [...sessions.values()].some(s => s.profileName === profileName && s.page);
  let page = profileAlreadyInUse ? null : existingPages.find(p => {
    if (claimedPages.has(p)) return false;
    try { return isBlank(p.url()); } catch { return false; }
  });

  if (page) {
    console.log(`Reusing existing blank tab (${page.url()}) for session "${sessionId}"`);
  } else if (profileAlreadyInUse) {
    page = await createPageInNewWindow(browser);
    if (page) console.log(`Session "${sessionId}" opened in its OWN window on shared profile "${profileName}"`);
    else { console.log(`[window] newWindow target failed for "${sessionId}" -- falling back to a tab`); page = await browser.newPage(); }
  } else {
    page = await browser.newPage();
  }

  // Always open the welcome page first — it shows session identity (sessionId,
  // profile, userDataDir) in the tab title and page body so users can tell
  // which Chrome window belongs to which session.
  const welcomeFile = path.join(__dirname, 'welcome.html');
  const userDataDir = freshProfile ? null : path.join(PROFILES_DIR, profileName);
  const welcomeParams = new URLSearchParams({ sessionId, profile: profileName });
  if (userDataDir) welcomeParams.set('userDataDir', userDataDir);
  const welcomeUrl = `file:///${welcomeFile.replace(/\\/g, '/')}?${welcomeParams}`;
  try {
    await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded', timeout: 10000 });
  } catch (e) {
    console.log(`Welcome page failed to load (non-fatal): ${e.message}`);
  }

  // v1.8.73: stamp the window IDENTITY at CREATE (per the AD contract: identity is per-HWND —
  // stamping only at park would leave the window reading "Google Chrome for Testing" during its
  // whole load). The welcome page just set the "(session: <id>)" title, so AD can resolve it.
  // Fire-and-forget; the park re-brands (with badge fallback) as belt-and-suspenders.
  if (process.platform === 'win32') { stampPupIdentity(sessionId).catch(() => {}); }

  // v1.8.68: the park is DEFINED here but KICKED OFF only after the real URL's navigation has
  // SETTLED (below). Rationale: the first cross-origin paint RAISES the window; if the window
  // is already placed on-screen (even at z-bottom), the user sees a blip on top of their work
  // (the adom/wiki thread's window John caught). Parking after settle means the paint-raise
  // hits a window still OFF-SCREEN — invisible, harmless. The window's first visible moment is
  // full-size, z-bottomed, and z-confirmed. Only for a freshly launched browser (_needsPark) —
  // a reused window is already placed, and re-parking it could hide a window the user has up.
  const kickoffPark = () => {
    if (foreground || process.platform !== 'win32') return;
    try {
      const be = browsers.get(profileName);
      if (be && be.browser && be.browser._needsPark) {
        be.browser._needsPark = false;
        be.browser._bgResult = 'parking';
        be.browser._bgPromise = parkSessionWindowDirect(page, sessionId)
          .then(r => {
            be.browser._bgResult = r;
            console.log(`[park] "${sessionId}": ${r}`);
            // v1.8.53: badge IMMEDIATELY after the park (the title suffix already exists —
            // no more blind 1.5s/3.5s waits John noticed) + one safety retry.
            brandPupWindow(sessionId).catch(() => {});
            setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 1200);
            return r;
          })
          .catch(e => (be.browser._bgResult = 'error'));
      }
    } catch (e) {}
  };
  // v1.8.36: SNAPPY auto-flash. The window already exists + is backgrounded here (the
  // welcome page loaded from a local file), so flash the taskbar NOW — do not wait for the
  // real URL's domcontentloaded, which for heavy sites is another 1–3s. Set _flashState so
  // the open handler's later scheduleAutoFlash debounces (no double flash). Fire-and-forget.
  if (!foreground && !silent && process.platform === 'win32') {
    try { _flashState.set(sessionId, Date.now()); flashViaAD(sessionId, {}); } catch (e) {}
  }

  if (url) {
    // Apply stored basic-auth creds for this URL's host BEFORE navigating
    // so the Chrome native auth dialog never appears. No-op if no match.
    try {
      const matchedHost = await credentialVault.applyCredentialsToPage(page, url);
      if (matchedHost) console.log(`[creds] applied to session "${sessionId}" first nav: matched ${matchedHost}`);
    } catch (e) {
      console.log(`[creds] applyCredentialsToPage on launchSession failed: ${e.message}`);
    }
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    // v1.8.68: wait for the page to SETTLE (load event, capped at 2.5s) while the window is
    // still OFF-SCREEN, so the first paint's window-raise is invisible. Skip the wait if the
    // page is already complete.
    if (!foreground && process.platform === 'win32') {
      try {
        const ready = await page.evaluate(() => document.readyState).catch(() => 'unknown');
        if (ready !== 'complete') {
          await Promise.race([
            new Promise(res => { try { page.once('load', res); } catch (e) { res(); } }),
            new Promise(res => setTimeout(res, 2500)),
          ]);
        }
      } catch (e) {}
      // Late-paint belt-and-suspenders: one more bottom re-assert well after settle.
      setTimeout(() => { reassertBottomAfterNav(sessionId).catch(() => {}); }, 3000);
    }
  }
  // Park now — after settle (url path) or right away (welcome-only path). The paint-raise
  // already happened off-screen; the window's first visible frame is full-size at z-bottom.
  kickoffPark();

  // Belt and suspenders: close any tab in this Chrome that's NOT claimed by
  // a registered session. This catches two cases:
  //   (a) leftover blank tabs from reconnect scenarios
  //   (b) tabs Chrome restored from session-restore (despite our
  //       prepareCleanProfile pre-launch pass) — pup is AI-driven, the AI
  //       knows what tabs it wants, so any unclaimed page is by definition
  //       a stale leftover from a prior run.
  // Tabs registered by other live sessions on this same Chrome (multi-session
  // shared profile) ARE in refreshedClaimed and stay open.
  let restoredTabsClosed = 0;
  try {
    const after = await browser.pages();
    const refreshedClaimed = new Set();
    for (const s of sessions.values()) {
      for (const t of s.tabs) refreshedClaimed.add(t.page);
    }
    refreshedClaimed.add(page);
    for (const p of after) {
      if (refreshedClaimed.has(p)) continue;
      try {
        let pUrl = '';
        try { pUrl = p.url() || ''; } catch {}
        await p.close({ runBeforeUnload: false });
        restoredTabsClosed++;
        if (!isBlank(pUrl)) {
          console.log(`Closed restored leftover tab (${pUrl}) on session "${sessionId}" launch`);
        }
      } catch {}
    }
  } catch {}
  if (restoredTabsClosed > 0) {
    console.log(`Session "${sessionId}" launch: closed ${restoredTabsClosed} stale tab(s) — pup is AI-driven, no session-restore needed`);
  }

  // Create the session with this page as its first tab.
  const session = createSession(profileName);
  // v1.9.26: sessions on the authed wiki profile are authed-INTENT — seed the flag NOW, before
  // the first identity stamp, so the FIRST taskbar icon is already the signed-in one. Windows
  // LATCHES a button's icon at creation and in-place re-stamps often do not repaint (proven live:
  // ex-wikiin registered icon=wiki-in yet kept the plain book), so correcting the icon later is
  // unreliable — the first stamp must be right. The real auth probe still runs after open and
  // corrects the flag (and the tab glyph) if the ~30-day session actually expired.
  if (profileName === WIKI_AUTH_PROFILE) session._wikiAuthed = true;
  // v1.8.16: stamp the declared owner (advisory thread/task label) at create.
  if (owner) session.owner = owner;
  // v1.6.3+: stash the per-session downloadPath BEFORE attachTab — attachTab
  // reads it to apply Page.setDownloadBehavior on the new page. Default
  // resolves in attachTab itself if unset.
  if (downloadPath) session._downloadPath = downloadPath;
  attachTab(session, sessionId, page);
  session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution
  sessions.set(sessionId, session);
  activeSessionId = sessionId;

  // Track refCount
  const be = browsers.get(profileName);
  if (be) be.refCount++;

  // Append sessionId (and profile if different) to the page title so user can identify windows
  try {
    const titleSuffix = profileName !== sessionId
      ? ` (session: ${sessionId} | profile: ${profileName})`
      : ` (session: ${sessionId})`;
    await page.evaluate((suffix) => {
      // v1.9.43: ONE named handle for the title observer. An adopted (dragged-out) tab must be able
      // to disconnect the PREVIOUS session's observer, or that observer keeps re-appending the old
      // suffix and fights every re-tag forever.
      const strip = (t) => String(t || '').replace(/\s*\(session:[^)]*\)/g, '').trim();
      try { if (window.__pupTitleObs) { window.__pupTitleObs.disconnect(); window.__pupTitleObs = null; } } catch (e) {}
      document.title = strip(document.title) + suffix;
      const obs = new MutationObserver(() => {
        try { if (!document.title.endsWith(suffix)) document.title = strip(document.title) + suffix; } catch (e) {}
      });
      obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });
      window.__pupTitleObs = obs;
    }, titleSuffix);
  } catch (e) {
    // Don't fail the session if title injection fails (e.g. about:blank)
  }

  // Persist to disk for recovery after bridge restart
  saveSessionFile(sessionId, profileName, url);

  // Stash one-shot launch metadata so the browser_open_window response can
  // surface it. Cleared after first read in getSessionInfo.
  session.lastLaunchMeta = { restoredTabsClosed };

  console.log(`Session "${sessionId}" launched as tab in profile "${profileName}" (${url || 'about:blank'}). Total sessions: ${sessions.size}`);
  return session;
}


// Append a tab to an existing session. Returns the new tabId.
async function addTabToSession(sessionId, url) {
  const session = sessions.get(sessionId);
  if (!session) {
    throw new Error(`Session "${sessionId}" not found. Use browser_open_window first.`);
  }
  const be = browsers.get(session.profileName);
  if (!be || !be.browser.isConnected()) {
    throw new Error(`Session "${sessionId}" browser is not connected.`);
  }
  // Defensive timeout: newPage() should be near-instant, but if the browser ever
  // stalls creating the target we fail THIS verb fast with a clear error instead of
  // hanging past AD's HTTP budget (which used to leave the bridge unresponsive).
  const page = await Promise.race([
    be.browser.newPage(),
    new Promise((_, rej) => setTimeout(() => rej(new Error('newPage timed out — the browser did not create the tab in 20s. Retry browser_open_tab; if it persists, browser_close_window + browser_open_window.')), 20000)),
  ]);
  const tabId = attachTab(session, sessionId, page, { makeActive: true });

  // v1.8.19: the " (session: <sid>)" title marker is now owned SOLELY by the
  // persistent evaluateOnNewDocument injector in attachTab (already installed above).
  // We deliberately do NOT install a second, per-tab MutationObserver here — two
  // observers appending different suffixes duel forever and hang the tab (the bug this
  // release fixes). tabIds are surfaced via browser_list_tabs, not the title.
  try {
    if (url) {
      try {
        const matchedHost = await credentialVault.applyCredentialsToPage(page, url);
        if (matchedHost) console.log(`[creds] applied to new tab "${tabId}": matched ${matchedHost}`);
      } catch (e) {
        console.log(`[creds] applyCredentialsToPage on new tab failed: ${e.message}`);
      }
      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    }
  } catch {}

  console.log(`Session "${sessionId}": added ${tabId} (${url || 'about:blank'}). Total tabs: ${session.tabs.length}`);
  return tabId;
}

// Check if a session's browser is still connected
function isSessionAlive(s) {
  const be = browsers.get(s.profileName);
  return be && be.browser.isConnected();
}

// Get active session, fallback to first, or return null
function getActiveSession() {
  if (activeSessionId && sessions.has(activeSessionId)) {
    const s = sessions.get(activeSessionId);
    if (isSessionAlive(s)) return s;
    sessions.delete(activeSessionId);
  }
  for (const [id, s] of sessions) {
    if (isSessionAlive(s)) {
      activeSessionId = id;
      return s;
    }
    sessions.delete(id);
  }
  activeSessionId = null;
  return null;
}


// Resolve a session by explicit sessionId or fall back to active.
// Does NOT change the global activeSessionId — safe for concurrent use.
// Returns { session, resolvedId } or null when sessionId was omitted and
// no live session exists. Never silently retargets when an explicit
// sessionId was passed but doesn't match — see resolveSessionOrError.
function resolveSession(args) {
  // Explicit sessionId path: don't fall through to a different session if
  // the requested one is missing or disconnected. v1.5.0 and earlier did,
  // and that's how chip-fetcher's "browser_navigate landed on the wrong
  // window" bug fired (caller passed the right sessionId, the bridge
  // silently retargeted to whatever it had).
  if (args.sessionId) {
    if (sessions.has(args.sessionId)) {
      const s = sessions.get(args.sessionId);
      if (isSessionAlive(s)) {
        s._lostBrowser = false;
        return { session: s, resolvedId: args.sessionId };
      }
      // Session entry exists but its browser is gone (CDP socket dropped,
      // browser process died, profile crashed). Caller will see a
      // structured error from resolveSessionOrError pointing at
      // browser_rescan.
    }
    return null;
  }
  // No sessionId passed: legacy fallback to active or first alive.
  if (activeSessionId && sessions.has(activeSessionId)) {
    const s = sessions.get(activeSessionId);
    if (isSessionAlive(s)) return { session: s, resolvedId: activeSessionId };
    sessions.delete(activeSessionId);
  }
  for (const [id, s] of sessions) {
    if (isSessionAlive(s)) return { session: s, resolvedId: id };
    if (!s._lostBrowser) sessions.delete(id);  // keep disconnected entries for rescan
  }
  return null;
}

// Like resolveSession but throws a STRUCTURED error (JSON-encoded message
// with errorCode + currentSessions + _hint) when nothing resolves.
// v1.5.1+: differentiates "session not found" from "session known but
// browser disconnected" so callers / Claude can give the right next-
// step recipe (browser_rescan vs browser_open_window).
function resolveSessionOrError(args) {
  const resolved = resolveSession(args);
  if (resolved) return resolved;
  const explicit = !!args.sessionId;
  const known = explicit && sessions.has(args.sessionId);
  const requested = args.sessionId || '(none)';
  const currentSessions = [...sessions.keys()];
  const liveSessions = currentSessions.filter(id => {
    const s = sessions.get(id);
    return s && isSessionAlive(s);
  });
  const disconnectedSessions = currentSessions.filter(id => {
    const s = sessions.get(id);
    return s && s._lostBrowser;
  });
  let payload;
  if (known) {
    // Session entry exists but Chrome socket is gone — recoverable.
    payload = {
      error: `Session "${requested}" is known to the bridge but its Chrome browser is currently disconnected. The Chrome window may still be alive on the user's desktop; the CDP socket dropped (network blip, sleep/wake, puppeteer hiccup) and the bridge hasn't reconnected yet.`,
      errorCode: 'session_disconnected',
      currentSessions,
      liveSessions,
      disconnectedSessions,
      _hint:
        `Run \`browser_rescan\` to attempt automatic recovery. The 30s ` +
        `health check will also auto-attempt, but rescan is faster + ` +
        `idempotent. If rescan reports the profile as unreachable, ` +
        `Chrome itself probably crashed — relaunch with browser_open_window.`,
    };
  } else if (explicit) {
    // Caller asked for a specific sessionId we have NO record of.
    payload = {
      error: `Session "${requested}" not found in bridge. Either it was never opened, it was closed via browser_close_window, or the bridge restarted and the on-disk session file was cleaned.`,
      errorCode: 'session_not_found',
      currentSessions,
      liveSessions,
      disconnectedSessions,
      _hint:
        `Live sessions right now: [${liveSessions.join(', ') || '(none)'}]. ` +
        (disconnectedSessions.length > 0
          ? `Disconnected sessions awaiting rescan: [${disconnectedSessions.join(', ')}] — try \`browser_rescan\` first. `
          : '') +
        `If the Chrome window IS still on the desktop but the bridge can't see ` +
        `it, run browser_rescan (it walks every Chrome target and recovers ` +
        `sessions by parsing the (session: X) tag from each page's title). ` +
        `If you need to start fresh, browser_open_window with the right sessionId.`,
    };
  } else {
    // No sessionId passed and nothing is alive.
    payload = {
      error: `No live browser sessions. Use browser_open_window first.`,
      errorCode: 'no_session',
      currentSessions,
      liveSessions,
      disconnectedSessions,
      _hint:
        disconnectedSessions.length > 0
          ? `Disconnected sessions exist on disk: [${disconnectedSessions.join(', ')}]. Run \`browser_rescan\` to see if any are recoverable before opening a new window.`
          : `Open a Chrome window with: browser_open_window {"sessionId":"...","profile":"...","url":"..."}`,
    };
  }
  const err = new Error(JSON.stringify(payload));
  err._isStructured = true;
  throw err;
}

// Send a structured-or-plain session resolve error. Mirror of
// sendTabResolveError — used by every dispatch site that calls
// resolveSessionOrError, so the structured payload (errorCode +
// currentSessions + _hint) reaches the caller intact.
function sendSessionResolveError(res, e) {
  if (e && e._isStructured) {
    try {
      const parsed = JSON.parse(e.message);
      sendJSON(res, { success: false, ...parsed });
      return;
    } catch {}
  }
  sendJSON(res, { success: false, error: e.message });
}

// v1.6.1+: the AUTO-RECOVER path. Tries every layer of recovery before
// surfacing a session-not-found error to the caller. Used by every
// browser_* dispatch site that previously called resolveSessionOrError.
//
// Recovery ladder:
//   (a) Session is alive → return immediately.
//   (b) Session is in `_lostBrowser` state (CDP socket dropped, sleep/wake,
//       transient blip): attemptReconnectProfile(profile) — uses session
//       file → DevToolsActivePort → running-process-cmdline-scan as
//       three tiers of fallback. If reconnect succeeds, rescanProfile
//       walks pages and re-attaches. Return the recovered session.
//   (c) Reconnect failed but we have a last-known URL (saved in the
//       session file at launch / nav time): kill any orphan Chrome
//       holding the profile, clean profile locks (lockfile / Singleton*
//       / DevToolsActivePort), then call launchSession to start a fresh
//       Chrome with the same profile + URL. Caller sees a transparent
//       recovery — same sessionId, fresh page, last URL re-loaded.
//   (d) Session entry doesn't exist BUT a session file does (e.g. bridge
//       restarted): same logic — try reconnect via session file, fall
//       back to relaunch with the file's recorded URL.
//   (e) Nothing works → throw the structured session_not_found /
//       session_disconnected error from resolveSessionOrError so the
//       caller (or its higher-level retry logic) gets a clear signal.
//
// The Docker container's contract: `browser_navigate / eval / screenshot
// / input_dispatch / errors / reload / fetch_url` should NEVER surface
// "Session closed" / "detached Frame" / "Browser disconnected" — those
// become internal retry signals, not user-facing errors.
async function recoverOrRelaunchSession(args) {
  // (a) Fast path: live session, no recovery needed.
  const fast = resolveSession(args);
  if (fast) return fast;

  const requestedId = args.sessionId;
  // No explicit sessionId → fall through to the strict-error path; we
  // can't auto-recover what we don't know. Caller handles this via
  // sendSessionResolveError as before (omitted-sessionId is a real
  // "no session" case, not a recovery case).
  if (!requestedId) {
    return resolveSessionOrError(args);
  }

  const inMem = sessions.get(requestedId);
  const sessionFile = readSessionFile(requestedId);
  // We need at minimum the profile name to do anything. Either from the
  // in-memory session entry or from the on-disk session file.
  const profileName = (inMem && inMem.profileName) || (sessionFile && sessionFile.profile);
  if (!profileName) {
    // Session is genuinely unknown to the bridge. Surface the structured
    // session_not_found error.
    return resolveSessionOrError(args);
  }
  const lastKnownUrl = (sessionFile && sessionFile.url) || null;

  console.log(
    `[recover] session "${requestedId}" needs recovery — profile="${profileName}", ` +
    `inMemory=${!!inMem}, sessionFile=${!!sessionFile}, lastKnownUrl=${lastKnownUrl || '(none)'}`
  );

  // (b) Try reconnect via the existing 3-tier path (session file →
  // DevToolsActivePort → running-process scan).
  let browser = await attemptReconnectProfile(profileName);
  if (browser) {
    try {
      await rescanProfile(browser, profileName);
    } catch (e) {
      console.log(`[recover] rescanProfile after reconnect failed: ${e.message}`);
    }
    // After rescan, the session may now be in the map.
    const after = resolveSession(args);
    if (after) {
      console.log(`[recover] session "${requestedId}" reconnected without relaunch`);
      return after;
    }
  }

  // (c)/(d) Reconnect failed (or got a browser but couldn't find the
  // session inside it). Kill any orphan Chrome for this profile, clean
  // profile locks, then relaunch fresh with the last-known URL.
  console.log(`[recover] reconnect insufficient — killing orphans + cleaning profile + relaunching session "${requestedId}"`);
  await killOrphanChromesForProfile(profileName);
  await cleanProfileLocks(profileName);
  // Drop the dead in-memory entry so launchSession creates a fresh one.
  // (launchSession's existing path checks for an existing session and
  // would try to navigate it — but its page handle is dead.)
  if (inMem) {
    sessions.delete(requestedId);
  }
  try {
    const session = await launchSession(requestedId, lastKnownUrl, { profile: profileName });
    if (session) {
      session._recovered = true;
      session._recoveryReason = browser ? 'reconnect-no-page' : 'relaunch';
      console.log(`[recover] session "${requestedId}" relaunched at ${lastKnownUrl || '(no URL)'}`);
      const final = resolveSession({ sessionId: requestedId });
      if (final) return final;
    }
  } catch (e) {
    console.log(`[recover] launchSession failed during recovery: ${e.message}`);
  }

  // (e) Total failure — surface the structured error so the caller can
  // tell the user (or escalate to browser_open_window with explicit URL).
  return resolveSessionOrError(args);
}

// Wrap an async page operation with a timeout so the HTTP response always comes back
async function withTimeout(promise, ms, label) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
  });
  try {
    return await Promise.race([promise, timeout]);
  } finally {
    clearTimeout(timer);
  }
}

// Periodic health check (v1.5.1+):
//
//   - Detect disconnected browsers and ATTEMPT RECONNECT before removing
//     them. Old (≤v1.5.0) behavior was to immediately delete the browser
//     entry + every session for that profile + every session file. That
//     erased recovery state for what was usually a transient network
//     blip (sleep/wake, puppeteer hiccup), leaving an orphaned-but-alive
//     Chrome window the bridge could no longer see. We now keep state
//     and try to recover.
//
//   - Sessions whose browser is gone get marked _lostBrowser; their entries
//     stay in the Map and on disk so browser_rescan / next health check
//     can recover them. Only delete a session file when its underlying
//     Chrome PID is confirmed dead.
//
//   - Stale-file cleanup still runs (PID dead → file deleted) so we don't
//     accumulate junk for Chromes that genuinely crashed.
setInterval(async () => {
  for (const [profileName, be] of browsers) {
    if (!be.browser.isConnected()) {
      console.log(`[health] browser for profile "${profileName}" disconnected — attempting reconnect`);
      browsers.delete(profileName); // attemptReconnectProfile re-adds on success
      const reconnected = await attemptReconnectProfile(profileName).catch(() => null);
      if (reconnected) {
        try {
          const r = await rescanProfile(reconnected, profileName);
          console.log(`[health] auto-reconnected + rescanned profile "${profileName}": ${r.reattached} reattached`);
        } catch (e) {
          console.log(`[health] rescan after reconnect failed for "${profileName}": ${e.message}`);
        }
      } else {
        console.log(`[health] could not reconnect profile "${profileName}" — sessions remain marked _lostBrowser`);
      }
    }
  }
  // Don't hard-delete sessions whose browser is currently disconnected —
  // they may recover on the next loop. Only purge sessions whose specific
  // PAGE handle is dead (tab was closed individually) AND profile is alive.
  for (const [id, s] of sessions) {
    if (s._lostBrowser) continue; // skip — awaiting reconnect
    if (!isSessionAlive(s)) {
      console.log(`[health] session "${id}" dead (page closed individually) — removing`);
      sessions.delete(id);
      deleteSessionFile(id);
      if (activeSessionId === id) {
        activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;
      }
    }
  }
  // v1.9.31: drain the window-opened ring here (no dedicated timer) so an idle desktop still
  // notices a dragged-out tab within one tick; active use catches it immediately via the tab verbs.
  drainWindowOpenedEvents().catch(() => {});
  // Clean stale session files whose PIDs are confirmed dead. Sessions that
  // are _lostBrowser but whose PID is alive STAY on disk for recovery.
  const files = listSessionFiles();
  for (const sf of files) {
    if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {
      console.log(`[health] stale session file "${sf.sessionId}" (PID ${sf.pid} dead) — deleting`);
      deleteSessionFile(sf.sessionId);
    }
  }
}, 30_000);

// v1.0.16: the single source of truth for per-verb HINTS — used BOTH by
// `browser_describe` (so AD's Verbs tab shows hint/related/pitfalls under each
// verb) AND by sendJSON's runtime injector below (so EVERY browser_* call
// self-documents inline, even for an AI that never read describe). This is the
// reference shape the cloud-owned bridges (kicad/fusion/native-browser) were
// asked to adopt: a calling AI should never have to guess and never have to
// read docs first. { verb: { hint, related[], pitfalls[] } }.
const VERB_META = {
  browser_open_window: { hint: 'Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process — never your real signed-in browser) that you then drive by sessionId. To drive the user\'s REAL Chrome/Edge, use the Adom browser extension\'s nbrowser_* verbs instead.', related: ['browser_navigate', 'browser_list_windows', 'browser_close_window'], pitfalls: ['sessionId is required and is your handle for every later call', 'profile defaults to sessionId — omit it for a plain "open in pup"', 'sessionIds are SHARED across all AI threads on this desktop — a generic id ("wiki", "test") may collide with another thread\'s window and navigating it away steals their work. Use a task-prefixed sessionId + pass owner:"<your-task>" so pup can protect it', 'reusing an existing sessionId with a new url NAVIGATES that window — check browser_status (shows owner per window) first if unsure'] },
  browser_open_tab: { hint: 'Adds a tab to an existing session; returns the new tabId.', related: ['browser_switch_tab', 'browser_list_tabs', 'browser_close_tab'], pitfalls: ['the session must already exist (browser_open_window first)'] },
  browser_close_tab: { hint: 'Closes one tab; the session stays open.', related: ['browser_close_window', 'browser_list_tabs'], pitfalls: ['omit tabId to close the active tab — closing the last tab may close the window'] },
  browser_close_window: { hint: 'Closes the whole session and all its tabs. Destructive.', related: ['browser_list_windows'], pitfalls: ['this ends the session — its sessionId is no longer valid afterward'] },
  browser_navigate: { hint: 'Drives the active tab to url and waits for load. Verify with a screenshot before asserting success.', related: ['browser_screenshot', 'browser_wait', 'browser_reload'], pitfalls: ['navigation can race SPA routing — confirm with browser_screenshot', 'some sites need browser_wait for a selector after navigate'] },
  browser_back: { hint: 'History back on the active tab.', related: ['browser_forward', 'browser_navigate'], pitfalls: [] },
  browser_forward: { hint: 'History forward on the active tab.', related: ['browser_back', 'browser_navigate'], pitfalls: [] },
  browser_reload: { hint: 'Reloads the active tab.', related: ['browser_navigate', 'browser_wait'], pitfalls: [] },
  browser_click: { hint: 'Clicks by CSS selector (preferred) or by x,y coords. Selector is more robust across layouts.', related: ['browser_type', 'browser_input_dispatch', 'browser_screenshot'], pitfalls: ['selector must match exactly one visible element — verify with browser_evaluate first', 'coords are viewport-relative; a scrolled element may be off-screen'] },
  browser_type: { hint: 'Types into the focused or selector-targeted field.', related: ['browser_click', 'browser_press_key'], pitfalls: ['focus the field first (browser_click the input) or pass selector', 'does not clear existing text — select-all + delete if you need a clean field'] },
  browser_press_key: { hint: 'Presses a key or chord (Enter, Ctrl+A, Tab).', related: ['browser_type', 'browser_input_dispatch'], pitfalls: ['chord syntax is "Ctrl+A" — verify the page has focus on the intended element'] },
  browser_screenshot: { hint: 'PNG of the page. Your primary way to SEE state — call it after navigate/click to confirm.', related: ['browser_screenshot_full_res', 'browser_navigate'], pitfalls: ['default is the viewport; pass fullPage:true for the whole scroll height'] },
  browser_evaluate: { hint: 'Runs JS in the page and returns the result — use it to read DOM state or verify selectors before acting.', related: ['browser_click', 'browser_fetch_url'], pitfalls: ['the expression must be serializable JSON in its return value', 'runs in the page context, not Node'] },
  browser_fetch_url: { hint: 'Fetches a URL using the tab\'s live cookies/session — for authed APIs the bare CLI can\'t reach. Returns bodyBase64.', related: ['browser_navigate', 'browser_evaluate'], pitfalls: ['for session-scoped URLs pass the tabId whose cookies you need', 'returns base64 — decode it caller-side'] },
  browser_input_dispatch: { hint: 'Low-level CDP mouse/keyboard input at coords — beats some overlays/iframes that swallow synthetic clicks.', related: ['browser_click', 'browser_press_key'], pitfalls: ['coords are viewport pixels', 'last-resort when browser_click can\'t reach the element — still cannot pass Cloudflare interactive challenges'] },
  browser_record_start: { hint: 'Starts video recording of the page for this session.', related: ['browser_record_stop', 'browser_record_status'], pitfalls: ['one recording per session — stop the current one before starting another'] },
  browser_record_stop: { hint: 'Stops recording and returns the saved file path.', related: ['browser_record_start', 'browser_record_list'], pitfalls: ['no-op if nothing is recording for this session'] },
  browser_record_status: { hint: 'Whether a recording is active for the session.', related: ['browser_record_start', 'browser_record_stop'], pitfalls: [] },
  browser_record_list: { hint: 'Lists saved page recordings.', related: ['browser_record_start'], pitfalls: [] },
  desktop_recorder_open: { hint: 'Opens the desktop screen-recorder UI window.', related: ['desktop_record_start', 'desktop_recorder_close'], pitfalls: [] },
  desktop_record_start: { hint: 'Starts a full desktop screen recording.', related: ['desktop_record_stop', 'desktop_record_status'], pitfalls: ['only one desktop recording at a time'] },
  desktop_record_stop: { hint: 'Stops the desktop recording and returns the file.', related: ['desktop_record_start', 'desktop_record_list'], pitfalls: [] },
  browser_readiness: { hint: 'Cold-start readiness probe: {ready, chromeForTestingInstalled, installing, installProgressPct}. On a fresh PC, poll this until ready:true before browser_open_window — the bridge auto-installs Chrome for Testing on its own.', related: ['browser_prewarm', 'browser_open_window'], pitfalls: ['ready:false with installing:true just means Chrome for Testing is still downloading — wait + poll, do NOT treat it as an error', 'installPhase:"failed" → surface lastError to the user (usually network/proxy blocking the download, or low disk)'] },
  browser_prewarm: { hint: 'Installs Chrome for Testing (the browser pup drives) WITHOUT opening a window — the warm-before-first-use path. Returns fast; poll browser_readiness for completion.', related: ['browser_readiness', 'browser_open_window'], pitfalls: ['the ~150 MB download needs network access to storage.googleapis.com', 'does not open a window — call browser_open_window once readiness reports ready:true'] },
  browser_describe: { hint: 'This list — the bridge\'s verbs + schemas + hints, for AD\'s Verbs tab. Read it once to learn the surface.', related: ['browser_status'], pitfalls: [] },
};

function sendJSON(res, obj, status = 200) {
  try {
    // v1.0.16: runtime _hint injection — if this response is for a documented
    // verb and the handler didn't already set its own _hint, attach the verb's
    // canonical hint (+ related) from VERB_META. So EVERY browser_* call carries
    // actionable guidance inline, matching the contract AD asks all bridges for.
    if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
      const meta = res && res._adomCommand && VERB_META[res._adomCommand];
      if (meta) {
        if (obj._hint === undefined && meta.hint) obj._hint = meta.hint;
        if (obj.related === undefined && Array.isArray(meta.related) && meta.related.length) obj.related = meta.related;
        // SDK "THE ONE PRINCIPLE": every verb response carries _next (the obvious next verbs) too.
        if (obj._next === undefined && (Array.isArray(meta.next) || Array.isArray(meta.related))) obj._next = meta.next || meta.related;
        if (obj.pitfalls === undefined && Array.isArray(meta.pitfalls) && meta.pitfalls.length) obj.pitfalls = meta.pitfalls;
      }
    }
    res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
    res.end(JSON.stringify(obj));
  } catch (e) { /* response already sent */ }
}

function readBody(req) {
  return new Promise(resolve => {
    let d = '';
    req.on('data', c => d += c);
    req.on('end', () => resolve(d));
  });
}

async function getSessionInfo(sessionId, session) {
  let url = null, title = null;
  try {
    url = session.page?.url() || null;
    title = session.page ? await session.page.title() : null;
  } catch (e) {}
  const info = {
    sessionId,
    url,
    title,
    active: sessionId === activeSessionId,
    errorCount: session.errors.length,
    profile: session.profileName,
    tabCount: session.tabs.length,
    activeTabId: session.activeTabId,
    // v1.8.16: advisory ownership — which AI thread/task opened this window.
    owner: session.owner || null,
    ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null,
    // v1.8.23: last thing the agent did here {tabId, verb, note, ts} — for the user +
    // Session Monitor to see WHICH window, WHAT, and WHEN.
    lastAgentUpdate: session._lastAgentUpdate || null,
    // v1.8.26: did background-by-default actually take? 'backgrounded' (good),
    // 'still-foreground' (focus steal — a bug), 'no-window', or null (foreground open
    // or non-Windows). Self-verified by re-reading the OS foreground window post-open.
    // v1.8.70: who put this window on the user's screen, when, and their stated reason —
    // the audit answer to "why did this foreground?" in one status query.
    lastForeground: session._lastForeground || null,
    background: (() => {
      try {
        const bg = browsers.get(session.profileName)?.browser?._bgResult || null;
        // v1.8.59: a park-failed window is sitting invisible off-screen (deliberately — better
        // than popping over the user's work). Any status/list query opportunistically retries
        // the heal, so it converges to 'backgrounded' without waiting for a tab touchpoint.
        if (bg === 'park-failed') healOffscreenWindow(session, sessionId).then(r => { try { if (r) browsers.get(session.profileName).browser._bgResult = r; } catch (e) {} }).catch(() => {});
        return bg;
      } catch (e) { return null; }
    })(),
    // v1.8.45: PROGRAMMATIC flash state. 'none' = not flashed; 'pending' = flashed and the
    // user has NOT activated it since (taskbar still lit); 'cleared' = the user activated it
    // (clicked its taskbar button) after the flash, so the tint is gone. Computed from the
    // server-side flash timestamp vs the in-page focus latch (window.__pupFocusTs, set by the
    // injected 'focus' listener). Lets a caller answer "which windows are still flashing?"
    // without a screenshot.
    flash: await (async () => {
      try {
        const key = session._sessionId || sessionId;
        const flashTs = _flashState.get(key) || 0;
        if (!flashTs) return 'none';
        let focusTs = 0;
        try { focusTs = await session.page.evaluate(() => window.__pupFocusTs || 0); } catch (e) {}
        return (focusTs && focusTs > flashTs) ? 'cleared' : 'pending';
      } catch (e) { return null; }
    })(),
  };
  // One-shot: surface launch-time metadata (restored-tab cleanup count)
  // so callers can verify session-restore prevention worked. Drop after read.
  if (session.lastLaunchMeta) {
    info.restoredTabsClosed = session.lastLaunchMeta.restoredTabsClosed;
    delete session.lastLaunchMeta;
  }
  return info;
}

// v1.8.16: shared ownership gate for mutating verbs (navigate / close). Returns a
// refusal payload when the caller DECLARED an owner that mismatches the session's
// (send it as-is), else null. Anonymous callers pass (advisory model — we can't
// authenticate threads), but open_window's hints teach them to declare.
function ownershipRefusal(session, sessionId, args, verb) {
  const caller = (args && typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;
  if (!session || !session.owner || !caller || caller === session.owner || (args && args.takeover)) return null;
  let curUrl = null; try { curUrl = session.page?.url() || null; } catch {}
  const age = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;
  return {
    success: false,
    errorCode: 'session_owned_by_another_thread',
    error: `Session "${sessionId}" belongs to another thread (owner "${session.owner}", opened ${age} min ago, currently showing ${curUrl}).`,
    sessionId, owner: session.owner, requestedBy: caller, currentUrl: curUrl, ageMinutes: age,
    _hint: `STOP — this window belongs to AI thread "${session.owner}", not you ("${caller}"). ${verb} on it would break that thread's work. Open your OWN window instead: browser_open_window with a UNIQUE task-prefixed sessionId (e.g. "${caller}-web") and owner:"${caller}". Only pass takeover:true if the user EXPLICITLY told you to repurpose this window. browser_status lists every window + its owner.`,
  };
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const pathname = url.pathname;

  // CORS preflight
  if (req.method === 'OPTIONS') {
    res.writeHead(204, {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    });
    res.end();
    return;
  }

  try {
    // GET /health
    if (pathname === '/health') {
      const sessionList = [];
      for (const [id, s] of sessions) {
        let pageUrl = null;
        try { pageUrl = s.page.url(); } catch (e) {}
        sessionList.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });
      }
      sendJSON(res, {
        status: 'ok',
        bridge: 'puppeteer',
        version: BRIDGE_VERSION,
        port: PORT,
        browserCount: browsers.size,
        sessionCount: sessions.size,
        activeSession: activeSessionId,
        sessions: sessionList,
        chrome: chrome.readiness(),
      });
      return;
    }

    // POST /command — generic command dispatch
    if (pathname === '/command' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      const command = body.command;
      const args = body.args || {};
      res._adomCommand = command; // v1.0.16: lets sendJSON inject this verb's _hint/related

      // v1.8.23: AUTO-FLASH + agent-update tracking for state-mutating verbs on an EXISTING
      // session (browser_open_window flashes from inside its own handler, once the session
      // exists). evaluate is read-only iff {readOnly:true}. {silent:true} per verb or the
      // session default suppresses. Runs BEFORE the handler — signals "the agent is
      // touching this window" regardless of the verb's outcome.
      if (MUTATING_VERBS.has(command) && command !== 'browser_open_window' && command !== 'browser_open_tab') {
        const isReadOnlyEval = (command === 'browser_eval' || command === 'browser_evaluate') && args.readOnly;
        if (!isReadOnlyEval) {
          const sid = args.sessionId || activeSessionId;
          const s = sid && sessions.get(sid);
          if (s) { recordAgentUpdate(s, command, args, args.tabId || s.activeTabId); scheduleAutoFlash(s, { silent: !!args.silent }); }
        }
      }

      // v1.9.57: light the taskbar progress bar while a thread is driving THIS window. Broader than
      // MUTATING_VERBS above on purpose — a screenshot or an eval is still an agent working in that
      // window, and that is exactly what the user wants to see. Status/polling verbs are excluded
      // (see PASSIVE_VERBS) or every window would read as permanently busy.
      if (command && command.startsWith('browser_') && !PASSIVE_VERBS.has(command)) {
        try { markSessionBusy(args.sessionId || activeSessionId); } catch (e) {}
      }

      switch (command) {
        // v1.0.15: self-describe — AD's "Verbs" tab POSTs {command:'browser_describe'}
        // and renders this verbatim: one entry per documented verb with
        // {name, summary, input, output, timeoutSeconds, example}. (browser_ is this
        // bridge's primary prefix, so AD calls it browser_describe.)
        case 'browser_describe': {
          // v1.0.16: each entry merges its VERB_META (hint/related/pitfalls) so AD's
          // Verbs tab renders the full self-doc — the reference shape for all bridges.
          const V = (name, summary, input, timeoutSeconds, example) => {
            const m = VERB_META[name] || {};
            return { name, summary, input, output: { success: 'bool' }, timeoutSeconds,
                     statusVerb: null, longRunning: false, example: example || {},
                     hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };
          };
          sendJSON(res, { success: true, bridge: 'puppeteer', verbs: [
            V('browser_open_window', 'Open a generic pup window — a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. ALWAYS opens in the BACKGROUND (does not steal focus; never disturbs the user\'s main work; still fully screenshottable/drivable) UNLESS you pass foreground:true — do that ONLY when the user explicitly wants to watch. Pass width+height (+ optional x,y, screen px) to place it at a specific size/position for screenshot framing — this NO LONGER foregrounds it (sizing stays in the background too; default unsized is maximized). profile defaults to sessionId. OWNERSHIP: sessionIds are shared across ALL AI threads — use a task-prefixed sessionId + owner:"<your-task>" so pup can refuse other threads stealing your window (reusing an existing id with a new url navigates it away; declared cross-owner reuse errors with session_owned_by_another_thread unless takeover:true). (For the user\'s REAL Chrome/Edge, use the Adom extension\'s nbrowser_* verbs.)', { sessionId: 'required str — task-prefix it (e.g. "mytask-web"); generic ids collide across threads', url: 'optional str', profile: 'optional str — defaults to sessionId', owner: 'optional str — your thread/task label; lets pup protect this window from other threads', takeover: 'optional bool — claim a window owned by another thread (only when the user explicitly asks)', foreground: 'optional bool — request showing the window on the user\'s screen. GATED (v1.8.70): requires foregroundReason too, else the open succeeds but stays BACKGROUND with foregroundDenied set. Only when the USER explicitly asked to watch.', foregroundReason: 'required WITH foreground:true — quote/paraphrase what the user said that justifies putting a window on their screen (min 10 chars; logged + recorded in lastAgentUpdate, auditable)', width: 'optional int px — sizes for screenshot framing, stays background', height: 'optional int px', x: 'optional int px', y: 'optional int px', wikiView: 'optional "authed"|"public" — for wiki.adom.inc URLs. Default (omit) = PUBLIC/anonymous view (logged out, what the world sees). "authed" routes to pup\'s reserved persistent authed-wiki profile (30-day SSO session; first use needs a one-time login — the response says wikiLoginNeeded). Open the same page BOTH ways to verify publish visibility (e.g. did private source leak to the public?).', appName: 'optional str — the APP this window is driving (e.g. "shotlog"); shown in the hover tooltip', appIconB64: 'optional base64 PNG — override the app icon worn in the taskbar overlay. Usually UNNECESSARY: pup auto-fetches the page\'s own <link rel="icon"> favicon (the adom-ui-design standard artifact every app must serve) and wears it automatically. An app-looking URL with NO favicon gets a reprimand hint.', updateNote: 'optional str — a short "what I did" note, surfaced back in list_tabs/list_windows as lastAgentUpdate so the user knows what changed', autoFlash: 'optional bool (default true) — mutating verbs auto-flash this window\'s taskbar (debounced, never steals focus) so the user notices the agent touched it; pass false to make this window silent by default', silent: 'optional bool — suppress the auto-flash for just this call' }, 60, { sessionId: 'mytask-web', owner: 'mytask', url: 'https://example.com', width: 960, height: 540 }),
            V('browser_open_tab', 'Open a new tab in an EXISTING session/window (returns {tabId, tabCount, activeTabId} — no need to diff list_tabs). This is the right verb for multi-URL work: ONE window with many tabs (one taskbar button), not many windows. The window stays in the background; the tab auto-flashes the taskbar (debounced) so the user notices. The USER can click the window\'s taskbar button at any time to view it — pup never pulls it back down.', { sessionId: 'required str', url: 'optional str', updateNote: 'optional str — short "what I did" note surfaced in list_tabs as lastAgentUpdate', silent: 'optional bool — suppress the auto-flash for this call' }, 30, { sessionId: 'work', url: 'https://example.com' }),
            V('browser_close_tab', 'Close a tab.', { sessionId: 'required str', tabId: 'optional str (default active)' }, 15, { sessionId: 'work' }),
            V('browser_close_window', 'Close a window/session (its tabs go too).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),
            V('browser_wiki_set_view', 'Flip a WIKI pup window between the logged-in and public view IN PLACE (relaunches the same window under the other cookie jar). This is the callback for the per-window taskbar right-click jump-list task — it is a USER-initiated action (the window is brought on-screen). AI threads should NOT call this; pass wikiView:"authed"|"public" on browser_open_window instead.', { sessionId: 'required str', view: 'required "authed"|"public"' }, 45, { sessionId: 'wiki-bmv080', view: 'authed' }),
            V('browser_navigate', 'Navigate the active tab to a URL.', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://example.com' }),
            V('browser_back', 'History back.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),
            V('browser_forward', 'History forward.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),
            V('browser_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),
            V('browser_click', 'Click an element (by selector or coords).', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', selector: 'button#go' }),
            V('browser_type', 'Type text into the focused/selected field.', { sessionId: 'required str', text: 'required str', selector: 'optional str' }, 20, { sessionId: 'work', text: 'hello' }),
            V('browser_press_key', 'Press a key / chord (e.g. Enter, Ctrl+A).', { sessionId: 'required str', key: 'required str' }, 15, { sessionId: 'work', key: 'Enter' }),
            V('browser_screenshot', 'Screenshot the page (PNG; fullPage optional). NOTE: fullPage only covers DOCUMENT-level scroll — on SPAs that scroll inside a nested container it returns just the viewport + a nestedScroll hint; use browser_screenshot_element or browser_scroll for those.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),
            V('browser_screenshot_element', 'Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node — to capture the enclosing CARD, add closest (e.g. {text:"Download…", closest:".download-card"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like browser_screenshot.', { sessionId: 'required str', selector: 'CSS selector (or use text)', text: 'visible text to match (or use selector)', closest: 'optional selector — climb to the nearest ancestor matching it (the card container)', padding: 'optional int px around the element', deep: 'optional bool — pierce shadow DOM (default true)' }, 30, { sessionId: 'work', selector: '.download-card' }),
            V('browser_scroll', 'Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window — window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.', { sessionId: 'required str', dy: 'optional int px (down+/up-)', dx: 'optional int px', toSelector: 'optional CSS selector to scroll to', toText: 'optional visible text to scroll to', block: 'optional start|center|end (for scroll-to; default center)', behavior: 'optional auto|smooth' }, 30, { sessionId: 'work', toText: 'Download' }),
            V('browser_set_viewport', 'Set the page viewport (CDP device-metrics) deterministically, independent of the OS window size — fixes a too-small innerWidth so fullPage/element shots render at a known width. width+height required.', { sessionId: 'required str', width: 'required int px', height: 'required int px', deviceScaleFactor: 'optional number (default 1)' }, 15, { sessionId: 'work', width: 1280, height: 900 }),
            V('browser_evaluate', 'Evaluate JS in the page; returns the result.', { sessionId: 'required str', expression: 'required str' }, 30, { sessionId: 'work', expression: 'document.title' }),
            V('browser_fetch_url', 'Fetch a URL\'s content via the browser (cookies/session intact).', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://api.example.com' }),
            V('browser_input_dispatch', 'Low-level input (mouse/keyboard) at coords — beats some overlays.', { sessionId: 'required str', type: 'required str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', type: 'mousePressed', x: 100, y: 200 }),
            V('browser_record_start', 'Start recording the page (video).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),
            V('browser_record_stop', 'Stop recording; returns the saved file.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),
            V('browser_record_status', 'Recording status for a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),
            V('browser_record_list', 'List recordings.', {}, 10, {}),
            V('desktop_recorder_open', 'Open the desktop screen-recorder UI.', {}, 20, {}),
            V('desktop_record_start', 'Start a desktop screen recording.', { outPath: 'optional str' }, 20, {}),
            V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}),
            V('browser_readiness', 'Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.', {}, 10, {}),
            V('browser_prewarm', 'Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine\'s installed Chrome/Edge.', { wait: 'optional bool — block until installed', setDefault: 'optional bool (default true) — pin CfT as the default' }, 240, {}),
            V('browser_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it — SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native "click YES on the UAC" notification and, if it expires, re-notifies with a one-tap "Approve now" (all autonomous — no AI turn). Poll browser_readiness.chromeStablePhase: installing → awaiting_uac → ready. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool — with browser:"chrome", install real Chrome if missing', elevate: 'optional "auto"(default)|true|false — auto: quiet install, prompt only if it needs admin; false: never prompt; true: go straight to the prompt', executablePath: 'optional — pin an exact browser binary' }, 240, {}),
            V('browser_lower_os_window', 'Send a pup window to the BACKGROUND (behind the user\'s windows) without stealing focus — still fully drivable. The default for open; use this to re-hide a window you foregrounded.', { sessionId: 'optional str' }, 15, {}),
            V('browser_raise_os_window', 'Bring a pup window to the FOREGROUND (user\'s screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason — a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, "show me"). It STEALS focus — to just signal "I updated this window", use browser_alert_window (or the auto-flash), NOT raise. After the show-me: browser_lower_os_window to return it to the background. Alias: browser_focus_window.', { sessionId: 'optional str', foregroundReason: 'REQUIRED — why the user asked to see this window' }, 15, { foregroundReason: 'user said "show me the wiki page"' }),
            V('browser_alert_window', 'Flash the taskbar icon for a pup window — a non-intrusive "it\'s ready/DONE" nudge that does NOT steal focus. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window — use this for an explicit end-of-task "it\'s ready" nudge on top of that. STRONGLY prefer this (or the auto-flash) over browser_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.', { sessionId: 'optional str', stop: 'optional bool — CLEAR the highlight instead of flashing' }, 15, {}),
            V('browser_render_html', 'Render a local HTML string in the tab via page.setContent — UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.', { sessionId: 'required str', html: 'required HTML string', waitUntil: 'optional (domcontentloaded default)', updateNote: 'optional note for lastAgentUpdate', silent: 'optional bool — suppress the auto-flash' }, 30, { sessionId: 'work', html: '<h1>Café</h1>' }),
            // Session/tab/window management + probes (were routed but undocumented).
            V('browser_status', 'List all open pup sessions + their tabs (NOT a readiness check — use browser_readiness for that).', {}, 10, {}),
            V('browser_list_windows', 'List every open session/window with its tabs.', {}, 10, {}),
            V('browser_switch_window', 'Make a session the active one (for verbs that default to active).', { sessionId: 'required str' }, 10, { sessionId: 'work' }),
            V('browser_list_tabs', 'List the tabs in a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),
            V('browser_switch_tab', 'Activate a tab WITHIN its session/window (idempotent — safe to call when the tab is already active). Does NOT raise a background window to the user\'s screen: the tab becomes the window\'s visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge — good citizenship for long-lived shared windows.', { sessionId: 'required str', tabId: 'required str', updateNote: 'optional str', silent: 'optional bool' }, 10, { sessionId: 'work', tabId: 'tab-2' }),
            V('browser_screenshot_full_res', 'FULL-resolution page screenshot (NOT downscaled — for saving to disk, NOT for Read: it can exceed Claude\'s image limit). Use browser_screenshot for AI-readable shots.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),
            V('browser_errors', 'Console errors + failed network requests for a session/tab (clears by default).', { sessionId: 'required str', tabId: 'optional str', clear: 'optional bool (default true)' }, 10, { sessionId: 'work' }),
            V('browser_wait', 'Wait N ms (or for a selector) before the next step.', { sessionId: 'required str', ms: 'optional int', selector: 'optional str' }, 60, { sessionId: 'work', ms: 500 }),
            V('browser_rescan', 'Recover orphaned sessions: reconnect dropped CDP sockets + rebuild sessions from window titles. Pass adoptOrphans:true to also adopt untagged tabs.', { adoptOrphans: 'optional bool' }, 30, {}),
            V('browser_close', 'Close ALL pup sessions + windows at once (destructive).', {}, 20, {}),
            V('desktop_record_status', 'Status of the desktop screen recorder + any active clip.', {}, 10, {}),
            V('desktop_record_list', 'List completed desktop recordings on disk.', {}, 10, {}),
            V('desktop_recorder_close', 'Close the desktop screen-recorder UI.', {}, 15, {}),
            V('browser_configure', 'Get/set user-facing pup preferences (persisted across bridge updates). taskbarGrouping: "split" (default — one taskbar button PER WINDOW, each badged with its own tab count; best for telling AI threads apart) | "grouped" (all pup windows stack under ONE "Adom Pup" button, badge shows window count; tidy taskbar). Applies to live windows immediately. Ask the USER which they prefer before changing it — this is their taskbar.', { taskbarGrouping: 'optional "split"|"grouped"' }, 15, { taskbarGrouping: 'grouped' }),
            V('browser_describe', 'This list — the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),
          ] });
          return;
        }
        // --- Session management commands ---
        case 'browser_open_window': {
          if (!args.sessionId) {
            sendJSON(res, { success: false, error: 'sessionId is required. Example: browser_open_window { "sessionId": "dart2", "profile": "dart2", "url": "https://..." }' });
            return;
          }
          // ── Cold-start gate (v1.8.72: THE DEMARCATION GATE) — READ-ONLY detect; never blocks ──
          // pup's browser is Chrome for Testing, PERIOD (the Aditya incident: a silent branded-
          // Chrome fallback let a Google sign-in trigger Chrome's profile-creation/consolidation
          // machinery and strand the user). An unpinned NEW-window open therefore REQUIRES cached
          // CfT: if it isn't there yet, kick the ~150 MB install in the BACKGROUND and return the
          // pollable {installing:true} — the caller polls browser_readiness and retries. This wait
          // is rare (AD prewarns CfT at install; flip of its skip-when-branded-exists default is
          // filed) and only ever hits a box's FIRST pup use. The user's installed Chrome/Edge is
          // available ONLY by explicit pin — browser_use {browser:"chrome"|"edge"} — eyes open.
          // Existing/live sessions are exempt (their browser is already running).
          {
            const rd = chrome.readiness();   // detect-only; never downloads
            const _def = (() => { try { return chrome.readDefault(); } catch (e) { return null; } })();
            const _pinnedBranded = !!(_def && _def.kind && !/chrome-for-testing|cft/i.test(_def.kind));
            const _cftCached = (() => { try { return chrome.detectChrome().installed; } catch (e) { return false; } })();
            const _sessionAlive = sessions.has(args.sessionId) || !!(browsers.get(args.profile || args.sessionId) && browsers.get(args.profile || args.sessionId).browser);
            const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive;
            if (!rd.ready || _needsCft) {
              if (rd.installPhase === 'failed') {
                sendJSON(res, {
                  success: false,
                  errorCode: rd.lastErrorCode || 'chrome_install_failed',
                  error: `No Chrome/Edge installed and the Chrome-for-Testing fallback failed to install: ${rd.lastError}`,
                  readiness: rd,
                  _hint: 'Chrome for Testing (pup\'s dedicated browser) failed to install — usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with browser_prewarm once the cause is fixed. If the user explicitly accepts driving their installed Chrome/Edge instead (identity-bleed risk — see the demarcation doctrine): browser_use {browser:"chrome"|"edge"}, then retry.',
                });
                return;
              }
              // No Chrome/Edge AND no cached CfT. If the disk is too low to hold CfT,
              // don't kick off a doomed 150 MB download — fail fast with a disk hint.
              if (rd.lowDisk) {
                sendJSON(res, {
                  success: false,
                  errorCode: 'chrome_install_no_disk',
                  readiness: rd,
                  error: `No Chrome/Edge on this machine and only ${rd.diskFreeMb} MB free — not enough to install Chrome for Testing (~800 MB needed).`,
                  _hint: `This box has no Chrome or Edge, and only ${rd.diskFreeMb} MB free disk — too low to download Chrome for Testing (~150 MB download, ~600 MB unpacked). Ask the user to free up disk space, then retry; OR install Edge/Chrome (Edge ships with Windows and needs almost nothing). Do NOT keep retrying the download — it will fail until disk is freed.`,
                });
                return;
              }
              // Fetch CfT in the background and tell the caller to poll+retry. This is
              // pup's dedicated browser being provisioned — NOT an error, and NOT a reason
              // to fall back to the user's branded browser.
              if (!rd.installing) chrome.ensureChromeReady({ background: true });
              sendJSON(res, {
                success: false,
                errorCode: 'chrome_for_testing_installing',
                installing: true,
                statusVerb: 'browser_readiness',
                readiness: rd,
                error: 'Chrome for Testing (pup\'s dedicated sandboxed browser) is not cached yet — downloading it now (~150 MB, one time).',
                _hint: 'FIRST-USE PROVISIONING (not an error): pup deliberately drives Chrome for Testing, its OWN sandboxed browser — it does NOT silently fall back to the user\'s installed Chrome/Edge, whose profile/sign-in machinery bleeds into pup\'s anonymous windows (sign-in → "create a profile?" → duplicate profiles → lost user). The download runs in the BACKGROUND: tell the user pup is provisioning its browser (~a minute or two, one time ever), poll `browser_readiness` until {ready:true}, then re-issue this browser_open_window. Only if the user EXPLICITLY wants their installed browser driven (rare; identity-bleed risk): browser_use {browser:"chrome"|"edge"} pins it, then retry.',
              });
              return;
            }
          }
          // pup is a generic, non-profile browser: each session is a fresh isolated
          // profile (its own Chrome/Edge process), never the user's real signed-in
          // browser. `profile` names that isolated user-data-dir; default it to the
          // sessionId so a bare "open in pup" (just sessionId + url) works cleanly.
          // (To drive the user's REAL Chrome/Edge, that's the Adom browser EXTENSION's
          // nbrowser_* verbs — a separate bridge, not pup.)
          // v1.8.96: wikiView:"authed" → ALWAYS the reserved vault profile. The 30-day SSO login
          // lives ONLY in adom-wiki-authed, so authed access MUST use that jar regardless of any
          // profile the thread passed (that's what makes ONE login serve EVERY thread). Each
          // session still gets its OWN window; they share only the cookie jar (proven: two
          // different threads, both authed, separate windows).
          if (args.wikiView === 'authed' && isAdomUrl(args.url)) args.profile = WIKI_AUTH_PROFILE;
          if (!args.profile) args.profile = args.sessionId;
          const sessionId = args.sessionId;
          // v1.6.3+: optional downloadPath override. Defaults to the user's
          // home Downloads dir, which is what desktop_watch_files polls by
          // default — the two ends meet without configuration. Caller can
          // override per-session via this arg if they want downloads to
          // land elsewhere (e.g. straight into a chip-fetcher staging dir).
          const downloadPath = (args.downloadPath && typeof args.downloadPath === 'string')
            ? args.downloadPath
            : path.join(os.homedir(), 'Downloads');
          // v1.8.70: THE FOREGROUND GATE (open-time half; mirrors abe's dual key). The flag
          // alone is NOT enough — without foregroundReason the open still SUCCEEDS but the
          // window stays BACKGROUND (abe semantics: withhold the foregrounding, not the work),
          // and the response says exactly what was missing. A granted foreground is logged +
          // recorded in lastAgentUpdate so the user can always audit who put a window on their
          // screen and why. Also accepts abe's arg name userRequestedForeground.
          // v1.8.92: app identity args — the driving app's icon/name for the window overlay.
          const _appIconB64 = (typeof args.appIconB64 === 'string' && args.appIconB64.length > 50) ? args.appIconB64 : null;
          const _appName = (typeof args.appName === 'string' && args.appName.trim()) ? args.appName.trim() : null;
          const fgRequested = !!(args.foreground || args.raise || args.show || args.userRequestedForeground);
          const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;
          const fgGranted = fgRequested && !!fgReason;
          const fgDenied = fgRequested && !fgReason;
          if (fgGranted) console.log(`[foreground] "${sessionId}" open GRANTED — reason: ${fgReason}`);
          let session;
          try {
            session = await launchSession(sessionId, args.url, {
              freshProfile: !!args.freshProfile,
              profile: args.profile,
              strictPermissions: !!args.strictPermissions,
              downloadPath,
              nativeSite: !!args.nativeSite,
              owner: (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null,
              takeover: !!args.takeover,
              // v1.8.24: background-by-default is enforced DURING launch (focus handed
              // back to the user's window the instant pup's window appears) unless the
              // caller explicitly wants it foreground (v1.8.70: AND supplied a reason).
              foreground: fgGranted,
              silent: !!args.silent || args.autoFlash === false,
            });
          } catch (e) {
            if (e && e.code === 'session_owned_by_another_thread') {
              const d = e.details || {};
              sendJSON(res, {
                success: false,
                errorCode: 'session_owned_by_another_thread',
                error: e.message,
                ...d,
                _hint: `STOP — do NOT take this window. Session "${d.sessionId}" was opened by another AI thread (owner "${d.owner}") and is currently showing ${d.currentUrl}. Navigating it away would destroy that thread's work. Do ONE of: (1) open your OWN window with a UNIQUE sessionId — prefix it with your task, e.g. "${d.requestedBy}-web" (RECOMMENDED); (2) if the user EXPLICITLY told you to repurpose this window, re-send with takeover:true to claim it; (3) browser_status first to see all windows + owners before picking a sessionId. Generic ids like "wiki"/"test"/"main" collide across threads — always task-prefix yours.`,
              });
              return;
            }
            throw e;
          }
          // One-shot reuse/steal report from launchSession (existing-session path).
          const reuse = session && session._reuseMeta ? session._reuseMeta : null;
          if (session) delete session._reuseMeta;
          // Stash on the session so attachTab uses it for any subsequent
          // tab open or popup auto-attach in this session.
          if (session) session._downloadPath = downloadPath;
          if (session && _appIconB64) { session._appIconB64 = _appIconB64; session._appIconMime = 'image/png'; }
          if (session && _appName) session._appName = _appName;
          // v1.8.50: the background watchdog runs concurrently with navigation. Await its
          // SELF-VERIFIED verdict (bounded) so the response's `background` field is the real
          // outcome ('backgrounded'/'still-foreground'), never a guess mid-park.
          try {
            const bp = session && browsers.get(session.profileName)?.browser?._bgPromise;
            if (bp) await Promise.race([bp, new Promise(r => setTimeout(r, 7000))]);
          } catch (e) {}
          // v1.8.92: wear the driving app's favicon in the overlay (page-served or explicit).
          let _appIconWorn = false;
          if (session && process.platform === 'win32') {
            try { _appIconWorn = await Promise.race([applyAppOverlay(session, sessionId), new Promise(r => setTimeout(() => r(false), 4000))]); } catch (e) {}
            // v1.8.99: run the favicon-vs-count decider (a window may open with multiple tabs).
            try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {}
          }
          const appIconHint = appIconReprimand(args.url, _appIconWorn);
          // v1.8.93: wiki dual-view hint + auth-state check.
          let _wikiAuth = null;
          if (session && isAdomUrl(args.url)) {
            if (args.wikiView === 'authed') { try { _wikiAuth = await Promise.race([checkWikiAuth(session.page), new Promise(r => setTimeout(() => r({ authed: false }), 3500))]); } catch (e) {} }
          }
          const wikiHint = wikiViewHint(args.url, args.wikiView === 'authed' ? 'authed' : 'public', _wikiAuth);
          // v1.8.97: gentle in-tab glyph — 🔓 authed (logged in), 📖 public.
          if (session && isAdomUrl(args.url)) {
            const label = (args.wikiView === 'authed' && _wikiAuth && _wikiAuth.authed) ? '\u25CF Logged in' : '\u25CB Public';
            try { await setWikiModeGlyph(session.page, label); } catch (e) {}
          }
          const info = await getSessionInfo(sessionId, session);
          // v1.8.0: BACKGROUND BY DEFAULT. pup is the AI's workspace, not the user's —
          // a window that pops to the foreground and covers what the user is doing is
          // disruptive. v1.8.17: background is now the HARD default — a window is
          // foregrounded ONLY when the caller EXPLICITLY asks (foreground/raise/show:true,
          // e.g. the user said "let me watch" / a recording). Sizing/positioning NO LONGER
          // foregrounds: a sized window is placed via CDP (works fine while occluded) and
          // STILL pushed to the back, so screenshot-framing never disturbs the user. The
          // window stays fully drivable + screenshottable in the background (occluded, not
          // minimized). Order matters: size FIRST, then send to back, so the final z-order
          // is background even after the bounds call.
          const w = Number(args.width), h = Number(args.height), wx = Number(args.x), wy = Number(args.y);
          const sized = Number.isFinite(w) && Number.isFinite(h);
          const foreground = fgGranted; // v1.8.70: the gate's verdict, not the raw flag
          if (sized) {
            try { await setSessionWindowBounds(session, { x: wx, y: wy, width: w, height: h }); } catch (e) {}
            // v1.8.21: ALSO pin the page VIEWPORT (CDP device-metrics) to width×height.
            // The OS-window bounds alone don't guarantee a matching inner viewport (DPI /
            // window-state quirks left it at ~170px on some laptops); page.setViewport makes
            // innerWidth/innerHeight deterministic, which is what fullPage/element shots need.
            try { await session.page.setViewport({ width: Math.round(w), height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1 }); } catch (e) {}
          }
          // v1.8.69: re-assert background after an explicit sizing call — Z-ORDER ONLY.
          // The old call here was the legacy osBackgroundWindowByPid, whose PS park MOVES the
          // window to 0,0 sized-to-monitor — silently clobbering the exact x/y/w/h the caller
          // just asked for (every sized open snapped back to the primary monitor's origin,
          // which broke placing windows on a second monitor). AD state:'bottom' with
          // force:false touches nothing but z, and respects a user-held window.
          if (!foreground && sized) {
            try {
              await chrome.adCommand('desktop_set_window_state', { titleContains: `(session: ${sessionId}`, state: 'bottom', force: false }, { timeoutMs: 5000 });
            } catch (e) {}
          }
          // v1.8.26: record whether this window is meant to be on screen — gates every
          // later bringToFront (navigate/reload/switch_tab must not raise a bg window).
          if (session) session._foreground = foreground;
          // v1.9.3: remember this window's wiki view so the taskbar jump-list toggle (and any
          // later relaunch) knows which cookie jar it's on. Record the "public" profile too so
          // toggling back from authed restores the original jar, not just the sessionId default.
          if (session && isAdomUrl(args.url)) {
            session._wikiView = (args.wikiView === 'authed') ? 'authed' : 'public';
            if (session._wikiView === 'public') session._publicProfile = session.profileName;
            // v1.9.12: seed the icon state so the FIRST brand already carries the right glyph.
            session._wikiAuthed = !!(_wikiAuth && _wikiAuth.authed);
          }
          if (session && fgGranted) {
            session._lastForeground = { ts: Date.now(), verb: 'browser_open_window', reason: fgReason };
            try { recordAgentUpdate(session, 'browser_open_window', { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}
          }
          // v1.8.23: session-level auto-flash default (autoFlash:false → this window never
          // auto-flashes, e.g. a chatty automation loop the user isn't watching).
          if (session && args.autoFlash === false) session._flashSilent = true;
          // Flash on open (a NEW window appearing IS the update) — unless foregrounded
          // (already on screen) or suppressed. Also stamp the first agent update.
          if (session) {
            recordAgentUpdate(session, 'browser_open_window', args, session.activeTabId);
            if (!foreground) scheduleAutoFlash(session, { silent: !!args.silent });
          }
          // v1.8.53: badge timing moved into the park (launchSession) — it fires the moment the
          // park verdict lands instead of blind 1.5s/3.5s timers. This is only a fallback for
          // the REUSED-window path (no park runs there, but the badge may have been cleared).
          if (process.platform === 'win32') {
            setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 300);
            // v1.9.3: attach/clear the per-window wiki view-toggle jump list once the window's
            // identity is stamped (registers the per-session AUMID if needed).
            setTimeout(() => { updateWikiJumplist(session, sessionId).catch(() => {}); }, 900);
            // v1.9.18: REPAINT the overlay after session._wikiAuthed is set. The first
            // applyAppOverlay above runs BEFORE the wiki auth result is recorded a few lines
            // down, so it always composited the favicon on the logged-out (dark) plate even for
            // an authed window. This second pass paints the correct plate colour.
            setTimeout(() => { updateTabCountBadge(session, sessionId).catch(() => {}); }, 1200);
          }
          // Verbose browser report: which browser we actually drove, what else is available.
          const brep = browserPickReport();
          const fgDenyHint = fgDenied ? ' ⛔ FOREGROUND DENIED: you passed foreground:true WITHOUT a foregroundReason, so the window opened in the BACKGROUND (the open itself succeeded). Foregrounding steals the user\'s screen+keyboard and is allowed ONLY when the USER explicitly asked to SEE this window. If they did: re-call with foreground:true + foregroundReason:"<quote/paraphrase what the user said>" (min 10 chars — it is recorded and auditable). If they did not: leave it background; browser_alert_window is the correct "it\'s ready" signal.' : '';
          const bgHint = foreground
            ? 'Opened in the FOREGROUND as EXPLICITLY requested (foreground/raise/show:true). Return it to the background with browser_lower_os_window once the user is done watching.'
            : `Opened in the BACKGROUND (behind the user's windows, did NOT steal focus)${sized ? ' — and placed at the requested size/position WITHOUT foregrounding, so framing it for a screenshot never disturbs the user' : ''}. This is the HARD default so pup never disrupts the user's main work. It is FULLY drivable here: browser_screenshot / browser_eval / browser_navigate all work while it stays hidden. ONLY bring it to the user's screen if they EXPLICITLY want to WATCH (a demo, a recording, a "show me") — pass foreground:true on open (or call browser_raise_os_window). Sizing alone does NOT foreground anymore.`;
          // v1.8.16: ownership/reuse verdict — reprimand grabs, bless refreshes.
          let ownHint = '';
          if (reuse && reuse.navigatedAway) {
            if (reuse.takeover) {
              ownHint = ` TAKEOVER: you claimed session "${sessionId}" from thread "${reuse.previousOwner}" (was showing ${reuse.previousUrl}) via takeover:true — you now own it. Only do this when the user explicitly asked.`;
            } else if (reuse.anonymousGrab) {
              ownHint = ` ⚠ YOU JUST TOOK OVER AN EXISTING WINDOW: session "${sessionId}" belonged to thread "${reuse.previousOwner}" and was showing ${reuse.previousUrl} — you navigated it away WITHOUT declaring an owner. If that window wasn't yours, you broke another AI thread's work: re-open ITS url (${reuse.previousUrl}) on this sessionId, then use your OWN unique task-prefixed sessionId. ALWAYS pass owner:"<your-task-name>" on browser_open_window so pup can protect windows across threads.`;
            } else if (reuse.sameOwner) {
              ownHint = ` Reused your own window (owner match) — navigated ${reuse.previousUrl} → new url.`;
            } else {
              ownHint = ` NOTE: reused EXISTING session "${sessionId}" (was showing ${reuse.previousUrl}, no owner declared by either side). If that window belonged to another AI thread, you just stole it — prefer a unique task-prefixed sessionId for new work, and pass owner:"<your-task-name>" so pup can enforce ownership.`;
            }
          } else if (reuse && reuse.reused) {
            ownHint = ' Reused the existing window (same/no url — benign refresh).';
          } else if (!args.owner) {
            // v1.8.57: teach anonymous CREATORS too (a long-lived app like adom-shotlog that
            // never declares owner gets zero cross-thread protection — its window can be
            // navigated away or closed by any other AI thread without pup refusing).
            ownHint = ` TIP: you created this window WITHOUT owner — pass owner:"<your-app-or-task>" on browser_open_window so pup REFUSES other threads' attempts to navigate/close it (session_owned_by_another_thread) instead of letting them take it. Especially important for a long-lived shared window.`;
          }
          sendJSON(res, {
            success: true,
            output: JSON.stringify({
              ok: true,
              defaultPermissionsApplied: !args.strictPermissions,
              defaultPermissions: args.strictPermissions ? [] : PERMISSIVE_DEFAULT_PERMISSIONS.map(p => `${p.name}=${p.setting}`),
              downloadsEnabled: !args.strictPermissions,
              downloadPath,
              // v1.4.0: which browser pup drove + the full fallback picture.
              browser: brep.browser,
              availableBrowsers: brep.availableBrowsers,
              defaultBrowser: brep.defaultBrowser,
              // v1.8.0: window placement — background unless explicitly foregrounded.
              placement: foreground ? 'foreground' : 'background',
              // v1.8.2: window size (if width/height were given). null = default maximized.
              size: sized ? { width: Math.round(w), height: Math.round(h), x: Number.isFinite(wx) ? Math.round(wx) : null, y: Number.isFinite(wy) ? Math.round(wy) : null } : null,
              // v1.8.16: reuse/ownership verdict (null = fresh window).
              reused: reuse ? { navigatedAway: reuse.navigatedAway, previousUrl: reuse.previousUrl, previousOwner: reuse.previousOwner, takeover: reuse.takeover } : null,
              owner: session.owner || null,
              appIconWorn: _appIconWorn || undefined,
              wikiView: isAdomUrl(args.url) ? (args.wikiView === 'authed' ? 'authed' : 'public') : undefined,
              wikiLoginNeeded: (args.wikiView === 'authed' && _wikiAuth && !_wikiAuth.authed) || undefined,
              foregroundDenied: fgDenied ? 'foreground_reason_required' : undefined,
              _hint: `${wikiHint}${authSteerHint(args.url)}${fgDenyHint}${appIconHint}${bgHint}${ownHint} ${brep._hint}${staleWindowsHint()}${adUpgradeHint()}`,
              ...info,
            }),
          });
          return;
        }
        case 'browser_switch_window': {
          const sessionId = args.sessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            const available = [...sessions.keys()];
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Available: ${available.join(', ')}` });
            return;
          }
          activeSessionId = sessionId;
          const session = sessions.get(sessionId);
          const info = await getSessionInfo(sessionId, session);
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...info }) });
          return;
        }
        case 'browser_list_windows': {
          const list = [];
          for (const [id, s] of sessions) {
            list.push(await getSessionInfo(id, s));
          }
          sendJSON(res, { success: true, output: JSON.stringify({ sessions: list, count: list.length, activeSession: activeSessionId }) });
          return;
        }
        case 'browser_configure': {
          // v1.8.82: user-facing pup preferences. Currently: taskbarGrouping 'split'|'grouped'.
          const valid = ['split', 'grouped'];
          const g = args.taskbarGrouping;
          if (g !== undefined && !valid.includes(g)) {
            sendJSON(res, { success: false, error: `taskbarGrouping must be one of ${valid.join('|')}`, });
            return;
          }
          // v1.9.57: agentActivity 'on'|'off' — the taskbar progress bar that marks which window a
          // thread is currently driving. On by default; this is the user's taskbar, so give them the
          // switch rather than assuming everyone wants the motion.
          const aa = args.agentActivity;
          if (aa !== undefined && !['on', 'off'].includes(aa)) {
            sendJSON(res, { success: false, error: `agentActivity must be one of on|off` });
            return;
          }
          if (aa) {
            savePupSettings({ agentActivity: aa });
            console.log(`[configure] agentActivity -> ${aa}`);
            if (aa === 'off') { for (const [sid] of _busyState) { setSessionProgress(sid, false).catch(() => {}); } _busyState.clear(); }
          }
          if (g) {
            savePupSettings({ taskbarGrouping: g });
            console.log(`[configure] taskbarGrouping -> ${g}`);
            // re-stamp every live window under the new identity scheme + refresh badges
            brandSweep('configure').then(async () => {
              try { const first = sessions.values().next().value; if (first) await updateTabCountBadge(first, sessions.keys().next().value); } catch (e) {}
              // split mode: refresh each window's own tab badge
              if (g === 'split') { for (const [sid, sess] of sessions) { updateTabCountBadge(sess, sid).catch(() => {}); } }
            }).catch(() => {});
          }
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, settings: pupSettings(), _hint: g ? `taskbarGrouping is now "${g}" — ${g === 'grouped' ? 'all pup windows stack under ONE "Adom Pup" taskbar button; its badge shows the WINDOW count' : 'each pup window gets its OWN taskbar button wearing its tab count'}. Applied to live windows immediately; persists across bridge updates (~/.adom/pup-settings.json).` : 'Current pup settings. Set with e.g. {"taskbarGrouping":"grouped"}.' }) });
          return;
        }
                case 'browser_rescan': {
          // v1.5.1+: orphan-recovery primitive. Walk every known profile
          // (in-memory + on-disk session files), attempt reconnect for any
          // whose CDP socket dropped, then walk pages of every connected
          // browser and rebuild session entries by parsing the (session: X)
          // tag from each page's title. Idempotent — pages already attached
          // to a live session are left alone.
          //
          // Args:
          //   adoptOrphans (optional bool, default false) — when true,
          //     pages with no (session: X) tag are attached under a
          //     generated sessionId so the caller can drive them. Useful
          //     when Chrome has tabs the bridge has never seen (e.g. user
          //     opened them manually). Default false to avoid surprising
          //     callers with random tabs in their sessions list.
          try {
            const adoptOrphans = !!args.adoptOrphans;
            const stats = await rescanAllProfiles({ adoptOrphans });
            brandSweep('rescan').catch(() => {}); // v1.8.79: unify icons on re-adopted windows
            // Build the post-rescan session list so callers can verify
            // their sessionId is back. liveSessions are usable; disconnected
            // are still awaiting Chrome to reappear (PID gone or port dead).
            const liveSessions = [];
            const disconnectedSessions = [];
            for (const [id, s] of sessions) {
              if (isSessionAlive(s)) {
                liveSessions.push(id);
              } else if (s._lostBrowser) {
                disconnectedSessions.push(id);
              }
            }
            sendJSON(res, {
              success: true,
              output: JSON.stringify({
                ok: true,
                rescanned: stats.profilesScanned,
                profilesReconnected: stats.profilesReconnected,
                profilesUnreachable: stats.profilesUnreachable,
                reattached: stats.totalReattached,
                orphansAdopted: stats.totalAdopted,
                liveSessions,
                disconnectedSessions,
                _hint:
                  disconnectedSessions.length > 0
                    ? `Some sessions still disconnected: [${disconnectedSessions.join(', ')}]. Their Chrome PID may be dead — check with browser_list_windows. browser_open_window (with the original sessionId + profile) will start fresh; the on-disk session file is overwritten.`
                    : (liveSessions.length > 0
                      ? `All known sessions are live: [${liveSessions.join(', ')}].`
                      : `No sessions known to the bridge. browser_open_window to start one.`),
              }),
            });
          } catch (e) {
            sendJSON(res, { success: false, error: `rescan failed: ${e.message}` });
          }
          return;
        }
        case 'browser_close_window': {
          const sessionId = args.sessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          // v1.8.16: refuse a declared cross-owner close (destroying another thread's window).
          { const refusal = ownershipRefusal(session, sessionId, args, 'browser_close_window'); if (refusal) { sendJSON(res, refusal); return; } }
          const profileName = session.profileName;

          // Close ALL tabs in this session (a window can now hold many tabs)
          const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;
          for (const t of [...session.tabs]) {
            try { await t.page.close(); } catch (e) {}
          }
          session.tabs.length = 0;
          session.activeTabId = null;
          sessions.delete(sessionId);
          deleteSessionFile(sessionId);
          clearSessionBusy(sessionId);                       // v1.9.57: drop any pending progress timer
          unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete)

          // Decrement refCount; kill browser if no sessions left using this profile
          const be = browsers.get(profileName);
          if (be) {
            be.refCount = Math.max(0, be.refCount - 1);
            if (be.refCount <= 0) {
              console.log(`No more sessions using profile "${profileName}" — killing browser`);
              await killBrowserProcess(be.browser, profileName);
              browsers.delete(profileName);
            }
          }

          if (activeSessionId === sessionId) {
            activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;
          }
          console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, remaining: sessions.size, activeSession: activeSessionId }) });
          return;
        }

        // v1.9.3 (AD #208): the taskbar jump-list callback — flip THIS wiki window between the
        // logged-in and public view IN PLACE. A view switch = a cookie-jar (userDataDir) switch,
        // fixed at browser launch, so it's a close-and-relaunch of the same sessionId under the
        // other profile at the same URL. Invoked when the user clicks the per-window taskbar
        // right-click task (runs AD's bundled CLI → this verb); it's a USER action, so the window
        // is brought on-screen. Not meant for AI threads (they pass wikiView on browser_open_window).
        case 'browser_wiki_set_view': {
          const sessionId = args.sessionId;
          const view = args.view === 'authed' ? 'authed' : 'public';
          if (!sessionId || !sessions.has(sessionId)) { sendJSON(res, { success: false, error: `Session "${sessionId}" not found` }); return; }
          const session = sessions.get(sessionId);
          let url = null; try { url = session.page.url() || null; } catch (e) {}
          if (!url || !isAdomUrl(url)) { sendJSON(res, { success: false, error: 'wiki view toggle applies only to Adom (adom.inc) URLs', output: JSON.stringify({ ok: false, url }) }); return; }
          const curView = session._wikiView === 'authed' ? 'authed' : 'public';
          const owner = session.owner || null;
          const publicProfile = session._publicProfile || sessionId;
          if (curView === view) {
            // Already in the requested view — just surface it (user clicked to see this window).
            session._foreground = true;
            try { await session.page.bringToFront(); } catch (e) {}
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, unchanged: true, url }) });
            return;
          }
          // v1.9.63 — IN-PLACE view switch, no close-and-relaunch.
          //
          // A wiki session cookie is per-PROFILE and a profile's userDataDir is fixed at Chrome
          // launch, which is why this used to tear the window down and relaunch it under the other
          // profile (the thing John watched vanish and never come back). Colby's browser-session
          // endpoint removes that: an ALREADY-LOGGED-IN page can mint its own single-use magic link
          // with nothing but its session cookie —
          //     POST /api/v1/auth/browser-session {next:"/adom/page"} -> {url, expires_in:60}
          // so the BRIDGE can mint on the desktop (no container token, no adom-wiki CLI) and just
          // navigate this window. The other direction is a cookie wipe plus a plain navigate. Either
          // way it is one hop and the window/tabs are never destroyed.
          //
          // Guard: only wipe cookies when this window owns its profile. The shared authed profile
          // backs OTHER windows too and clearing it would silently log them all out — fall through
          // to the legacy relaunch in that case.
          const _sharedAuthed = /adom-wiki-authed/i.test(session.profileName || '');
          const _canInPlace = (view === 'authed') ? true : !_sharedAuthed;
          if (_canInPlace) {
            try {
              let targetUrl = null;
              if (view === 'authed') {
                // Mint from ANY currently-authenticated pup page. Single-use + 60s TTL, so mint
                // immediately before navigating and never cache it (a live link IS a credential).
                let minter = null;
                for (const [, s2] of sessions) {
                  for (const t2 of (s2.tabs || [])) {
                    try {
                      if (!isAdomUrl(t2.page.url())) continue;
                      const a2 = await checkWikiAuth(t2.page);
                      if (a2 && a2.authed) { minter = t2.page; break; }
                    } catch (e) {}
                  }
                  if (minter) break;
                }
                if (minter) {
                  const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return '/'; } })();
                  targetUrl = await minter.evaluate(async (np) => {
                    try {
                      const r = await fetch('/api/v1/auth/browser-session', {
                        method: 'POST', credentials: 'same-origin',
                        headers: { 'content-type': 'application/json' },
                        body: JSON.stringify({ next: np }),
                      });
                      if (!r.ok) return null;
                      const j = await r.json();
                      if (!j || !j.url) return null;
                      // The endpoint does NOT honour a `next` in the body or query — it only ever
                      // returns `?ticket=...`. The CLI bakes the deep-link by APPENDING `&next=<path>`
                      // client-side, so do the same or the redeem lands on the wiki ROOT and the user
                      // loses the page they were on (measured: toggled to authed and landed on "/").
                      return j.url + '&next=' + encodeURIComponent(np);
                    } catch (e) { return null; }
                  }, nextPath);
                }
                if (!targetUrl) console.log(`[wikiview] "${sessionId}" no logged-in page to mint from — falling back to relaunch`);
              } else {
                try {
                  const cdp = await session.page.target().createCDPSession();
                  await cdp.send('Network.clearBrowserCookies');
                  await cdp.detach();
                } catch (e) {}
                targetUrl = url;
              }
              if (targetUrl) {
                await session.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 25000 });
                session._wikiView = view;
                const a3 = await checkWikiAuth(session.page).catch(() => ({ authed: false }));
                await setWikiAuthedState(session, sessionId, !!(a3 && a3.authed));
                try { await setWikiModeGlyph(session.page, (a3 && a3.authed) ? '● Logged in' : '○ Public'); } catch (e) {}
                session._wikiJumplistKey = undefined;   // the toggle's own label flips
                updateWikiJumplist(session, sessionId).catch(() => {});
                updateThumbnailTooltip(session, sessionId).catch(() => {});
                session._foreground = true;
                try { await session.page.bringToFront(); } catch (e) {}
                console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE (authed=${!!(a3 && a3.authed)}) — no relaunch`);
                sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, inPlace: true, authed: !!(a3 && a3.authed), url: session.page.url() }) });
                return;
              }
            } catch (e) { console.log(`[wikiview] "${sessionId}" in-place switch failed (${e.message}) — falling back to relaunch`); }
          }

          // v1.9.29 BUG FIX: carry EVERY tab across the switch. The old code relaunched only the
          // active tab's url and closed the rest, silently destroying the user's other tabs (John
          // lost a 5-tab research window to one jump-list click). Snapshot all urls + which was
          // active BEFORE the teardown, then restore them into the relaunched window.
          const _tabUrls = [];
          let _activeIdx = 0;
          try {
            (session.tabs || []).forEach((t, i) => {
              let u = null; try { u = t.page.url(); } catch (e) {}
              if (u && !/^(about:|chrome:|edge:|devtools:)/i.test(u)) {
                if (t.tabId === session.activeTabId) _activeIdx = _tabUrls.length;
                _tabUrls.push(u);
              }
            });
          } catch (e) {}
          if (!_tabUrls.length) _tabUrls.push(url);
          console.log(`[wiki] view toggle "${sessionId}": ${curView} → ${view} (${_tabUrls.length} tab(s), active #${_activeIdx + 1})`);
          // v1.9.9: tell the user IMMEDIATELY that their click registered — the relaunch takes a
          // few seconds and silence reads as "it did nothing". Replaced by the result caption below.
          const _switchMsg = view === 'authed'
            ? '\u{1F513} Switching to the LOGGED-IN view of the Adom wiki…'
            : '\u{1F4D6} Switching to the PUBLIC (logged-out) view of the Adom wiki…';
          // v1.9.11: OS caption (AD >=1.9.144). Fire-and-forget: the old window is torn down
          // moments later, so an OS-level surface is the ONLY thing that survives the switch.
          wikiCaption(_switchMsg, 30000).catch(() => {});
          // Close the current window (mirror browser_close_window) so we can relaunch under the
          // other cookie jar.
          const oldProfile = session.profileName;
          const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;
          for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }
          session.tabs.length = 0; session.activeTabId = null;
          sessions.delete(sessionId);
          deleteSessionFile(sessionId);
          unregisterPupAumid(_closingAppId).catch(() => {});
          const be = browsers.get(oldProfile);
          if (be) {
            be.refCount = Math.max(0, be.refCount - 1);
            if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }
          }
          // Relaunch the SAME sessionId under the new profile at the same URL.
          const newProfile = view === 'authed' ? WIKI_AUTH_PROFILE : publicProfile;
          let s2 = null;
          try { s2 = await launchSession(sessionId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true }); }
          catch (e) { sendJSON(res, { success: false, error: `wiki view relaunch failed: ${e.message}` }); return; }
          // v1.9.29: restore the OTHER tabs (in their original order) into the relaunched window.
          for (let i = 0; i < _tabUrls.length; i++) {
            if (i === _activeIdx) continue;
            try { await addTabToSession(sessionId, _tabUrls[i]); } catch (e) {}
          }
          // Re-activate the tab the user was actually on.
          try {
            const s3 = sessions.get(sessionId);
            const back = (s3 && s3.tabs && s3.tabs[0]) || null;
            if (back) { s3.activeTabId = back.tabId; await back.page.bringToFront(); }
          } catch (e) {}
          s2._wikiView = view;
          if (view === 'public') s2._publicProfile = newProfile;
          s2._foreground = true;
          s2._wikiJumplistView = undefined;
          s2._wikiJumplistKey = undefined;   // v1.9.62: the key is what gates the commit now
          // v1.9.7: the user clicked a per-window taskbar task → REALLY pop it to the foreground
          // (full OS SetForegroundWindow raise, not the old CDP-only maximize that left it behind
          // the user's active window — the reason John "never saw the switch work"). Plus a taskbar
          // flash as a belt-and-suspenders notice in case Windows' foreground lock denies the raise.
          await osRaiseSessionWindow(s2, sessionId);
          // v1.9.29 BUG FIX: VERIFY the window actually came back. John clicked the toggle, the old
          // window closed, captions fired, and NO window returned: the relaunch had happened but the
          // window stayed unfindable/off-screen (the identity stamp logged "No visible window"),
          // leaving him with nothing. A switch that cannot show its window is a FAILED switch, so
          // confirm the shell can see it and self-heal before reporting success.
          let _visible = false;
          for (let i = 0; i < 8 && !_visible; i++) {
            try {
              const fr = await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 });
              const fraw = fr && (typeof fr.output === 'string' ? fr.output : JSON.stringify(fr.output || ''));
              const fo = fraw ? JSON.parse(fraw) : {};
              _visible = !!((fo.data || fo).best);
            } catch (e) {}
            if (!_visible) {
              try { await healOffscreenWindow(s2, sessionId); } catch (e) {}
              try { await osRaiseSessionWindow(s2, sessionId); } catch (e) {}
              await new Promise(r => setTimeout(r, 700 * (i + 1)));
            }
          }
          if (!_visible) {
            console.log(`[wiki] "${sessionId}" switched but the window never became visible — telling the user`);
            wikiCaption('Adom wiki: the switch finished but the window did not come back on screen. Click the Adom Pup taskbar button to raise it.', 15000).catch(() => {});
          }
          try { flashViaAD(sessionId, {}); } catch (e) {}
          s2._lastForeground = { ts: Date.now(), verb: 'browser_wiki_set_view', reason: `user clicked the taskbar "switch to ${view} view" task` };
          try { recordAgentUpdate(s2, 'browser_wiki_set_view', { updateNote: `wiki view → ${view} (user clicked taskbar task)` }, s2.activeTabId); } catch (e) {}
          // v1.9.7: set an OPTIMISTIC glyph immediately (the window is on the authed profile, and the
          // common case IS logged in) so the tab reflects the switch the instant it pops, then
          // RESPOND — the window is already raised. Everything below runs in the BACKGROUND so a
          // click never waits on cosmetics: branding, favicon overlay, jump list, AND the auth
          // RE-check that corrects the glyph once the authed cookies/SPA settle (the first check
          // races the page load, so a logged-in window was briefly mislabeling "📖 Public" — John
          // saw exactly this).
          try { await setWikiModeGlyph(s2.page, view === 'authed' ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}
          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true, view, url, foregrounded: true,
            _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view (auth is re-confirmed a moment later; the tab label self-corrects if the ~30-day login has expired).`,
          }) });
          (async () => {
            if (process.platform === 'win32') {
              // v1.9.10: WAIT for the title suffix "(session: <id>)" to exist before branding —
              // branding immediately raced the title injector and AD found "No visible window
              // with title containing (session: wiki-demo)" ([badge] FAILED in the log).
              await new Promise(r => setTimeout(r, 1500));
              try { await brandPupWindow(sessionId); } catch (e) {}
              try { await applyAppOverlay(s2, sessionId); } catch (e) {}
              try { await updateWikiJumplist(s2, sessionId); } catch (e) {}
            }
            if (view === 'authed') {
              await new Promise(r => setTimeout(r, 300));
              let a = null;
              try { a = await checkWikiAuth(s2.page); } catch (e) {}
              try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}
              try { await setWikiAuthedState(s2, sessionId, a && a.authed); } catch (e) {}
              // v1.9.9: result caption — names WHO you're signed in as (John's ask), or says the
              // one-time sign-in is still needed. Replaces the "Switching…" caption (same id).
              const _doneMsg = a && a.authed
                ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`
                : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in — sign in in this window';
              wikiCaption(_doneMsg, 6000).catch(() => {});
            } else {
              try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}
              const _doneMsg = '\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view — what the world sees';
              wikiCaption(_doneMsg, 6000).catch(() => {});
            }
          })().catch(() => {});
          return;
        }

        // --- Tab-level commands ---
        case 'browser_open_tab': {
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            const available = [...sessions.keys()].join(', ');
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Use browser_open_window first. Available: [${available}]` });
            return;
          }
          try {
            const tabId = await addTabToSession(sessionId, args.url);
            const session = sessions.get(sessionId);
            const tab = getTabById(session, tabId);
            recordAgentUpdate(session, 'browser_open_tab', args, tabId); // v1.8.23
            scheduleAutoFlash(session, { silent: !!args.silent });
            updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80
            // v1.8.57: long-lived-app touchpoint (see switch_tab) — heal a stranded window,
            // re-badge debounced (v1.8.58: only debounce on SUCCESS so failures retry).
            if (process.platform === 'win32') {
              // v1.8.67: a new tab's first paint can raise its window — re-assert (force:false
              // skips a user-held window).
              if (!session._foreground) reassertBottomAfterNav(sessionId).catch(() => {});
              healOffscreenWindow(session, sessionId).catch(() => {});
              const now = Date.now();
              if (!session._badgedAt || now - session._badgedAt > 600000) {
                brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});
              }
            }
            const info = await describeTab(tab, session.activeTabId);
            // v1.8.23: return tabId + tabCount + activeTabId at the TOP level (was only inside `info`).
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, tabId, tabCount: session.tabs.length, activeTabId: session.activeTabId, ...info }) });
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'browser_render_html': {
          // v1.8.23: render a local HTML string in the tab via page.setContent — which
          // handles encoding correctly (UTF-8), unlike a data: URL that mojibakes accented
          // chars without an explicit charset. Pass {html}. Great for previewing generated
          // markup without writing a temp file or fighting data:-URL escaping.
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          if (typeof args.html !== 'string') {
            sendJSON(res, { success: false, error: 'browser_render_html requires an html string.', _hint: 'e.g. {"sessionId":"...","html":"<h1>Café</h1>"} — setContent renders it as UTF-8 (a data: URL would mojibake the é).' });
            return;
          }
          try {
            await withTimeout(tab.page.setContent(args.html, { waitUntil: args.waitUntil || 'domcontentloaded', timeout: 20000 }), 25_000, 'page.setContent');
            recordAgentUpdate(session, 'browser_render_html', args, tabId);
            scheduleAutoFlash(session, { silent: !!args.silent });
            let title = null; try { title = await tab.page.title(); } catch {}
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, title, bytes: args.html.length, _hint: 'Rendered your HTML in the tab (UTF-8, no data:-URL mojibake). Screenshot it or drive it like any page.' }) });
          } catch (e) { sendJSON(res, { success: false, error: `browser_render_html failed: ${e.message}` }); }
          return;
        }
        case 'browser_switch_tab': {
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          const tab = getTabById(session, args.tabId);
          if (!tab) {
            const available = session.tabs.map(t => t.tabId).join(', ');
            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });
            return;
          }
          session.activeTabId = tab.tabId;
          // v1.8.26: only raise the OS window for a session the user is watching. For a
          // background window, just track the active tab (pup targets tabs by id).
          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }
          // v1.8.57: switch_tab is a long-lived app's hot path (adom-shotlog switches its
          // shared window's tabs for hours) — self-heal a stranded window + re-badge here
          // (debounced) so the window stays clickable/badged across bridge restarts.
          if (process.platform === 'win32') {
            healOffscreenWindow(session, sessionId).catch(() => {});
            updateTabCountBadge(session, sessionId).catch(() => {}); // v1.9.0: overlay follows the active tab's favicon
            const now = Date.now();
            if (!session._badgedAt || now - session._badgedAt > 600000) {
              // v1.8.58: only debounce on SUCCESS — a failed badge retries on the next call.
              brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});
            }
          }
          const info = await describeTab(tab, session.activeTabId);
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info }) });
          return;
        }
        case 'browser_close_tab': {
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          if (!args.tabId) {
            sendJSON(res, { success: false, error: 'tabId is required for browser_close_tab' });
            return;
          }
          const idx = session.tabs.findIndex(t => t.tabId === args.tabId);
          if (idx === -1) {
            const available = session.tabs.map(t => t.tabId).join(', ');
            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });
            return;
          }
          const tab = session.tabs[idx];
          try { await tab.page.close(); } catch {}
          // The page.on('close', ...) handler in attachTab removes the tab from
          // session.tabs, but race-proof: remove here too if still present.
          const stillIdx = session.tabs.findIndex(t => t.tabId === args.tabId);
          if (stillIdx !== -1) session.tabs.splice(stillIdx, 1);
          if (session.activeTabId === args.tabId) {
            session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;
          }
          updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, closedTab: args.tabId, remaining: session.tabs.length, activeTabId: session.activeTabId }) });
          return;
        }
        case 'browser_list_tabs': {
          drainWindowOpenedEvents().catch(() => {}); // v1.9.32: cheap event-driven drag-out check
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          const tabs = [];
          for (const t of session.tabs) {
            const info = await describeTab(t, session.activeTabId);
            if (t.lastAgentUpdate) info.lastAgentUpdate = t.lastAgentUpdate; // v1.8.23: per-tab
            tabs.push(info);
          }
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, count: tabs.length, activeTabId: session.activeTabId, lastAgentUpdate: session._lastAgentUpdate || null, tabs }) });
          return;
        }

        case 'browser_alert_window': {
          // Flash the taskbar button for a session's window WITHOUT foregrounding it. v1.8.35:
          // {stop:true} (alias {clear:true}) CLEARS a lingering orange highlight instead.
          // (Pre-1.8.35 this wrongly called bringToFront — it raised the window instead of
          // flashing. Now it uses the real FlashWindowEx path, same as the auto-flash.)
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          try {
            const stop = !!(args.stop || args.clear);
            // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).
            const r = await flashViaAD(session._sessionId || sessionId, { stop });
            let applied = false;
            try { const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r); applied = !!(o && (o.ok || o.count)); } catch (e) {}
            // v1.8.45: FLASHW_STOP only stops the PULSE — the Win11 attention tint persists
            // until the window is ACTIVATED. So on {stop} we ALSO clear the tint for real via an
            // off-screen activate-and-restore (no pop, focus handed straight back). This is the
            // fix for "you said you cleared it but the button is still lit."
            let clearMethod = null;
            if (stop && process.platform === 'win32') {
              try {
                const br = browsers.get(session.profileName) && browsers.get(session.profileName).browser;
                if (br && (br._detachedPid || br._cdpPort)) clearMethod = osClearFlashByActivate(br._detachedPid, br._cdpPort);
              } catch (e) {}
              try { _flashState.delete(session._sessionId || sessionId); } catch (e) {}
            }
            const trulyCleared = stop && ['cleared', 'cleared-still-foreground', 'already-foreground'].includes(clearMethod);
            console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied}, clear=${clearMethod})`);
            const hint = stop
              ? (trulyCleared
                  ? `Flash truly cleared — activated the window off-screen to drop the Win11 attention tint (${clearMethod}), then handed focus back. No pop, no focus steal.`
                  : `Could not fully clear (${clearMethod || 'window not found'}). FLASHW_STOP ran (applied=${applied}) but the persistent tint needs activation and that step failed.`)
              : (applied ? 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.' : 'AD desktop_flash_window did not match the window (title changed? window closed?).');
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop ? trulyCleared : false, applied, clearMethod, _hint: hint }) });
          } catch (e) {
            sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });
          }
          return;
        }
        case 'browser_lower_os_window': {
          // Send a pup window to the BACKGROUND (behind the user's windows) without
          // stealing focus. It stays fully drivable + screenshottable. This is the
          // "get it off the user's screen" verb — the counterpart to raise/focus.
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          // v1.8.47b: park on-screen at z-bottom (screenshots keep working). An explicit lower is
          // a deliberate re-park, so clear the user-foreground latch too.
          const be = browsers.get(session.profileName);
          if (be && be.browser) be.browser._userForeground = false;
          const ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground) === 'backgrounded') : osSendWindowToBack(session.profileName);
          session._foreground = false;
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) });
          return;
        }
        case 'browser_raise_os_window':
        case 'browser_focus_window': {
          // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          // v1.8.70: THE FOREGROUND GATE (mirrors abe's userRequestedForeground+foregroundReason
          // dual key — same contract across Adom's browser surfaces). Raising steals the user's
          // screen and keyboard; it is allowed ONLY as a user-requested show-me, and the caller
          // must SAY SO in a recorded, auditable reason. Real incident: a thread raised a window
          // 2 minutes after a background open and the only forensics were a focus-latch
          // timestamp — never again.
          const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;
          if (!fgReason) {
            sendJSON(res, {
              success: false,
              errorCode: 'foreground_reason_required',
              error: 'Raise refused: foregroundReason is required.',
              _hint: 'Foregrounding steals the user\'s screen and keyboard focus. It is allowed ONLY when the USER explicitly asked to SEE this window (a "show me", a demo, a live recording). Call again with foregroundReason:"<quote or closely paraphrase what the user said that justifies this>" (min 10 chars) — the reason is logged, recorded in lastAgentUpdate, and auditable by the user. If the user did NOT ask to see it, do NOT raise: use browser_alert_window (taskbar nudge) or rely on the auto-flash. After a granted raise: do the one thing the user asked for, then browser_lower_os_window to return it to the background.',
            });
            return;
          }
          console.log(`[foreground] "${sessionId}" raise GRANTED — reason: ${fgReason}`);
          session._lastForeground = { ts: Date.now(), verb: command, reason: fgReason };
          try { recordAgentUpdate(session, command, { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}
          try {
            // v1.8.26: background windows launch OFF-SCREEN (-32000). To actually SHOW one,
            // move it back onto the primary monitor + maximize it (via CDP, no OS activation)
            // BEFORE we foreground it — otherwise "raise" would focus an invisible window.
            try {
              const cdp = await session.page.target().createCDPSession();
              const { windowId } = await cdp.send('Browser.getWindowForTarget');
              await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: 1280, height: 800, windowState: 'normal' } });
              await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
            } catch (e) {}
            // v1.8.23: BLUR the page's active element BEFORE raising. Raising steals the OS
            // focus, and a keystroke the user was mid-typing can land in whatever input the
            // pup page had focused (a form field became "John Lauer a"). With no focused
            // element, a stray key has nowhere to land. (The residual SetForegroundWindow
            // input-queue timing is AD-owned when AD intercepts this verb — flagged upstream.)
            try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}
            // First activate the tab within Chrome
            await session.page.bringToFront();
            activeSessionId = sessionId;
            session._foreground = true; // v1.8.26: user asked to watch — allow later raises

            // v1.9.67 — RAISE BY HWND FIRST (fixes "I click and no window comes up" on Edge boxes).
            //
            // The PID path below is skipped whenever `process()` is null, which is EVERY detached launch on
            // Windows — so on a box with no Chrome (pup falls back to Edge and caches it as the verified
            // default) the only thing that ran was the CDP bounds + bringToFront above. bringToFront activates
            // the TAB inside the window, not the OS window, so a window pup parked at the bottom of the
            // z-order stayed buried. Reproduced on ConfRoomROG: the verb returned ok:true, logged
            // "raise GRANTED", and the screen never changed. The PID fallback was Chrome-only too — it scanned
            // for child processes named "chrome.exe" and would never match msedge.exe.
            //
            // Resolving by the "(session: <id>)" title is browser-agnostic and names the EXACT window (the old
            // _detachedPid attempt raised the LAUNCHER's window). The raise stays in PowerShell rather than
            // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE — structurally
            // unable to take focus until that style is CLEARED, which is exactly what Raise() does.
            let _raisedByHwnd = false;
            if (process.platform === 'win32') {
              try {
                const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 }));
                const hwnd = fw && fw.best && fw.best.hwnd;
                if (hwnd) {
                  const psPath = path.join(require('os').tmpdir(), '_adom_focus_hwnd.ps1');
                  fs.writeFileSync(psPath, [
                    'Add-Type @"',
                    'using System; using System.Runtime.InteropServices;',
                    'public class WinFocusH {',
                    '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
                    '  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',
                    '  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
                    '  [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
                    '  [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr h, int i, int v);',
                    '  public static void Raise(IntPtr h){',
                    '    int ex = GetWindowLong(h, -20); SetWindowLong(h, -20, ex & ~0x08000000);',
                    '    if (IsIconic(h)) ShowWindow(h, 9); ShowWindow(h, 5); SetForegroundWindow(h);',
                    '  }',
                    '}',
                    '"@',
                    `[WinFocusH]::Raise([IntPtr]${hwnd})`,
                    "Write-Output 'focused'",
                  ].join('\n'), 'utf8');
                  const out = runPsHidden(psPath, { timeout: 8000 });
                  // v1.9.68: PS reports 'focused' even when Windows IGNORED the call. SetForegroundWindow is
                  // subject to the FOREGROUND LOCK: a process that does not already own the foreground cannot
                  // steal it, so it silently no-ops (measured: log said FOCUSED, screen unchanged). Use PS only
                  // for what it is uniquely good at — clearing WS_EX_NOACTIVATE and un-minimizing — then let AD,
                  // which owns a foreground-capable path, do the real raise on the SAME hwnd.
                  const _styleCleared = /focused/.test(out || '');
                  try {
                    const br = adPayload(await chrome.adCommand('desktop_bring_to_front', { hwnd, state: 'restore' }, { timeoutMs: 6000 }));
                    _raisedByHwnd = !!(br && (br.ok === undefined || br.ok));
                  } catch (e) { _raisedByHwnd = false; }
                  console.log(`[foreground] "${sessionId}" hwnd=${hwnd} styleCleared=${_styleCleared} adRaise=${_raisedByHwnd}`);
                } else { console.log(`[foreground] "${sessionId}" could not resolve an hwnd by title`); }
              } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }
            }

            // Get Chrome PID from the browser process
            const browserProcess = browsers.get(session.profileName)?.browser?.process();
            const pid = browserProcess?.pid;
            if (!_raisedByHwnd && pid && process.platform === 'win32') {
              // Write PowerShell script to temp file and execute (avoids quoting hell)
              const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');
              const psContent = [
                'Add-Type @"',
                'using System; using System.Runtime.InteropServices;',
                'public class WinFocus {',
                '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',
                '  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',
                '  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
                '  [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',
                '  [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr h, int i, int v);',
                // v1.8.32: background windows carry WS_EX_NOACTIVATE (they can never take focus).
                // To actually SHOW one, CLEAR that style first, then restore + foreground it.
                '  public static void Raise(IntPtr h){',
                '    int ex = GetWindowLong(h, -20); SetWindowLong(h, -20, ex & ~0x08000000);',
                '    if (IsIconic(h)) ShowWindow(h, 9); ShowWindow(h, 5); SetForegroundWindow(h);',
                '  }',
                '}',
                '"@',
                `$targetPid = ${pid}`,
                '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',
                'if ($p -and $p.MainWindowHandle -ne 0) {',
                '  [WinFocus]::Raise($p.MainWindowHandle)',
                "  Write-Output 'focused'",
                '} else {',
                '  Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',
                '    $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',
                '    if ($cp -and $cp.MainWindowHandle -ne 0) {',
                '      [WinFocus]::Raise($cp.MainWindowHandle)',
                "      Write-Output 'focused'",
                '      return',
                '    }',
                '  }',
                '}',
              ].join('\r\n');
              fs.writeFileSync(psScript, psContent);
              try {
                const result = runPsHidden(psScript, { timeout: 8000 }).toString().trim();
                console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);
              } catch (e) {
                console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);
              }
            }
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true }) });
          } catch (e) {
            sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });
          }
          return;
        }

        // --- Existing commands (operate on active session) ---
        case 'browser_launch': {
          sendJSON(res, { success: false, error: 'browser_launch is removed. Use browser_open_window with required sessionId and profile params.' });
          return;
        }
        case 'browser_screenshot': {
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          const fullPage = args.fullPage || false;
          const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;
          const ts = Date.now();
          let screenshotBuf = await withTimeout(
            tab.page.screenshot({ type: 'png', fullPage }),
            20_000, 'page.screenshot'
          );
          // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside
          // a nested container (the document itself doesn't grow), fullPage silently returns
          // just the visible viewport. Detect that and hand the caller the right tool.
          let nestedScrollHint = null;
          if (fullPage) {
            try {
              await ensureDeepHelpers(tab.page);
              const ns = await tab.page.evaluate(() => {
                const doc = document.scrollingElement || document.documentElement;
                const docScrolls = doc.scrollHeight > doc.clientHeight + 1;
                const main = window.__pupMainScroller();
                const nested = !docScrolls && main !== doc && main.scrollHeight > main.clientHeight + 1;
                return { docScrolls, nested, scroller: window.__pupDesc(main), scrollHeight: main.scrollHeight, clientHeight: main.clientHeight };
              });
              if (ns.nested) nestedScrollHint = `fullPage captured only the viewport: this page scrolls inside a NESTED container (${ns.scroller}, ${ns.scrollHeight}px of content vs ${ns.clientHeight}px visible), not the document — fullPage can't stitch that. To capture the whole thing, either browser_scroll {dy:...} through it and stitch, or (for one region) browser_screenshot_element {selector|text}. To capture a specific card, browser_screenshot_element is the one-shot fix.`;
            } catch (e) {}
          }

          // Downscale to keep the image under Claude's Read limit when sharp is
          // available; otherwise serve at capture size (sharp is optional — see the
          // processScreenshot helper). A ≤1568px image is the ideal path.
          const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);
          const shot = await processScreenshot(screenshotBuf, maxDim);
          screenshotBuf = shot.buf;

          const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);
          fs.writeFileSync(filePath, screenshotBuf);
          const sizeKB = Math.round(screenshotBuf.length / 1024);
          session.latestShot = filePath;
          const base64 = screenshotBuf.toString('base64');

          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true,
            sizeKB,
            filePath,
            base64,
            width: shot.width,
            height: shot.height,
            origWidth: shot.origWidth,
            origHeight: shot.origHeight,
            resized: shot.resized,
            ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size — the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed — see browser_readiness.' }),
            ...(nestedScrollHint ? { nestedScroll: true, _hint: nestedScrollHint } : {}),
            sessionId: resolvedId,
            tabId,
          }) });
          return;
        }
        case 'browser_set_viewport': {
          // v1.8.21: set the page viewport (CDP device-metrics) deterministically —
          // independent of the OS window size. Fixes "innerWidth came up ~170px" and lets a
          // fullPage/element shot render at a known width. width+height required.
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          const vw = Number(args.width), vh = Number(args.height);
          if (!Number.isFinite(vw) || !Number.isFinite(vh)) {
            sendJSON(res, { success: false, error: 'browser_set_viewport requires numeric width + height.', _hint: 'e.g. browser_set_viewport {"sessionId":"...","width":1280,"height":900}. Optional deviceScaleFactor (default 1).' });
            return;
          }
          const dsf = Number(args.deviceScaleFactor) || 1;
          try {
            await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });
            const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio }));
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, applied: { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf }, ...dims, _hint: `Viewport set to ${Math.round(vw)}×${Math.round(vh)} (innerWidth now ${dims.innerWidth}). Now browser_screenshot / browser_screenshot_element render at this size.` }) });
          } catch (e) { sendJSON(res, { success: false, error: `setViewport failed: ${e.message}` }); }
          return;
        }
        case 'browser_scroll': {
          // v1.8.21: first-class scroll. Resolves the ACTUAL scrolling element (nearest
          // ancestor whose scrollHeight>clientHeight, NOT just window — window.scrollTo
          // no-ops on nested-scroll SPAs). Supports scroll-by-delta {dy,dx} AND scroll-to
          // {toSelector|toText, deep} piercing shadow roots; returns the scroller's metrics
          // so the caller can confirm it landed.
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          await ensureDeepHelpers(tab.page);
          try {
            const r = await tab.page.evaluate((o) => {
              let target = null;
              if (o.toSelector) target = window.$deep(o.toSelector);
              else if (o.toText) target = window.deepText(o.toText);
              let scroller;
              if (o.toSelector || o.toText) {
                if (!target) return { found: false };
                scroller = window.__pupScrollableAncestor(target);
                target.scrollIntoView({ block: o.block || 'center', inline: 'nearest', behavior: o.behavior || 'auto' });
              } else {
                scroller = window.__pupMainScroller();
                scroller.scrollBy({ top: o.dy || 0, left: o.dx || 0, behavior: o.behavior || 'auto' });
              }
              return { found: true, scrollElement: window.__pupDesc(scroller), scrollTop: Math.round(scroller.scrollTop), scrollLeft: Math.round(scroller.scrollLeft), scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, atBottom: scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 };
            }, { dy: Number(args.dy) || 0, dx: Number(args.dx) || 0, toSelector: args.toSelector || null, toText: args.toText || null, block: args.block || null, behavior: args.behavior || 'auto' });
            if (!r.found) {
              sendJSON(res, { success: false, errorCode: 'scroll_target_not_found', error: `browser_scroll: could not resolve ${args.toSelector ? 'selector "'+args.toSelector+'"' : 'text "'+args.toText+'"'} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId });
              return;
            }
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, ...r, _hint: `Scrolled ${args.toSelector||args.toText ? 'to the target' : 'by delta'} inside ${r.scrollElement} — scrollTop ${r.scrollTop}/${r.scrollHeight - r.clientHeight}${r.atBottom ? ' (at bottom)' : ''}. Screenshot to confirm, or browser_screenshot_element to grab just a card.` }) });
          } catch (e) { sendJSON(res, { success: false, error: `browser_scroll failed: ${e.message}` }); }
          return;
        }
        case 'browser_screenshot_element': {
          // v1.8.21: screenshot ONE element, resolved by CSS selector OR visible text
          // (piercing shadow DOM), scrolled into view first. The clean fix for "capture
          // just this card" on component-heavy pages. Returns the same shape as
          // browser_screenshot (base64 + filePath, downscaled for Read).
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          if (!args.selector && !args.text) {
            sendJSON(res, { success: false, error: 'browser_screenshot_element requires a selector OR text.', _hint: 'e.g. {"selector":".download-card"} or {"text":"Download for your machine"}. Pierces shadow DOM by default.' });
            return;
          }
          await ensureDeepHelpers(tab.page);
          let handle;
          try {
            // Resolve by selector or text (shadow-piercing), then optionally climb to the
            // nearest matching ancestor with `closest` — e.g. {text:"Download…", closest:".card"}
            // grabs the whole CARD, not just the text node. Returns the FIRST text match
            // whose closest(sel) exists, so an ambiguous phrase (also in README prose) still
            // lands on the real widget.
            handle = await tab.page.evaluateHandle((sel, txt, closest) => {
              let el = sel ? window.$deep(sel) : null;
              if (!el && txt) {
                if (closest) {
                  // scan all text matches, pick the first whose closest(sel) resolves
                  const marks = [];
                  const walk = (root) => { const a = root.querySelectorAll('*'); for (const e of a) {
                    let own = ''; for (const c of e.childNodes) if (c.nodeType === 3) own += c.textContent;
                    if (own.toLowerCase().indexOf(txt.toLowerCase()) !== -1) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) marks.push(e); }
                    if (e.shadowRoot) walk(e.shadowRoot); } };
                  walk(document);
                  for (const m of marks) { const c = m.closest(closest); if (c) { el = c; break; } }
                } else { el = window.deepText(txt); }
              } else if (el && closest) { el = el.closest(closest) || el; }
              return el;
            }, args.selector || null, args.text || null, args.closest || null);
            const el = handle.asElement();
            if (!el) {
              try { await handle.dispose(); } catch {}
              sendJSON(res, { success: false, errorCode: 'element_not_found', error: `browser_screenshot_element: no element matched ${args.selector ? 'selector "'+args.selector+'"' : 'text "'+args.text+'"'}${args.closest ? ' with a closest("'+args.closest+'") ancestor' : ''} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId, _hint: 'If matching by text is ambiguous (the phrase also appears in prose), pass a `selector` for the widget, or a `closest` selector for its card container.' });
              return;
            }
            await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'center' }));
            await new Promise(r => setTimeout(r, 150)); // let layout settle after scroll
            const pad = Number(args.padding) || 0;
            // Document-relative clip (getBoundingClientRect + scroll) + captureBeyondViewport —
            // this is coordinate-correct even when the element is below the fold (puppeteer's
            // boundingBox()/clip spaces mismatched and captured the wrong region).
            const rect = await el.evaluate((e, p) => { const r = e.getBoundingClientRect();
              return { x: r.left + (window.scrollX || 0) - p, y: r.top + (window.scrollY || 0) - p, width: r.width + 2 * p, height: r.height + 2 * p, vw: r.width, vh: r.height };
            }, pad);
            if (rect.vw < 1 || rect.vh < 1) throw new Error('element has no rendered box (hidden/zero-size)');
            const rawBuf = await withTimeout(tab.page.screenshot({ type: 'png', captureBeyondViewport: true, clip: { x: Math.max(0, Math.round(rect.x)), y: Math.max(0, Math.round(rect.y)), width: Math.round(rect.width), height: Math.round(rect.height) } }), 20_000, 'element.screenshot(clip)');
            try { await handle.dispose(); } catch {}
            const shot = await processScreenshot(rawBuf, MAX_SCREENSHOT_DIM);
            const ts = Date.now();
            const filePath = path.join(SHOTS_DIR, `pup-el-${ts}.png`);
            fs.writeFileSync(filePath, shot.buf);
            session.latestShot = filePath;
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, matchedBy: args.selector ? 'selector' : 'text', sizeKB: Math.round(shot.buf.length / 1024), filePath, base64: shot.buf.toString('base64'), width: shot.width, height: shot.height, origWidth: shot.origWidth, origHeight: shot.origHeight, resized: shot.resized, _hint: 'Captured just that element (scrolled into view first). Read the filePath to see it.' }) });
          } catch (e) {
            try { if (handle) await handle.dispose(); } catch {}
            sendJSON(res, { success: false, error: `browser_screenshot_element failed: ${e.message}` });
          }
          return;
        }
        case 'browser_screenshot_full_res': {
          // v1.8.40+: full-resolution variant for the rare "save to disk for
          // external use" case (export to wiki, hand to a designer, etc.).
          // NEVER pass the resulting image to Claude via Read — full-res
          // browser viewports are typically 2559x1398 (or larger on hi-DPI
          // monitors), which exceeds Claude's 2000px many-image limit and
          // crashes the session. For AI-readable screenshots, use
          // browser_screenshot which forces a ≤1568px resize.
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          const fullPage = args.fullPage || false;
          const ts = Date.now();
          const screenshotBuf = await withTimeout(
            tab.page.screenshot({ type: 'png', fullPage }),
            20_000, 'page.screenshot'
          );
          // Probe dimensions for the warning payload — cheap. No resize, no
          // recompression — the bytes that hit disk are exactly what Chrome captured.
          const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf);
          const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);
          fs.writeFileSync(filePath, screenshotBuf);
          const sizeKB = Math.round(screenshotBuf.length / 1024);
          session.latestShot = filePath;
          const base64 = screenshotBuf.toString('base64');
          sendJSON(res, {
            success: true,
            output: JSON.stringify({
              ok: true,
              sizeKB,
              filePath,
              base64,
              width: meta.width,
              height: meta.height,
              sessionId: resolvedId,
              tabId,
              _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) — do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use browser_screenshot instead for AI-readable screenshots (auto-resized to ≤1568px). This command is for saving to disk / wiki / export only.`,
            }),
          });
          return;
        }
        // browser_evaluate is the catalog/manifest name; browser_eval the historical one.
        // Accept both, with either `expr` or `expression`.
        case 'browser_evaluate':
        case 'browser_eval': {
          if (!args.expr && args.expression) args.expr = args.expression;
          if (!args.expr) {
            sendJSON(res, { success: false, error: 'Missing required parameter "expr" (alias: "expression"). Usage: browser_eval { "sessionId": "...", "expr": "document.title" }' });
            return;
          }
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use
          // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.
          await ensureDeepHelpers(tab.page);
          let result;
          try {
            result = await withTimeout(
              tab.page.evaluate(new Function('return (' + args.expr + ')')),
              20_000, 'page.evaluate'
            );
          } catch (e) {
            result = { __error: e.message };
          }
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });
          return;
        }
        // ── Element-level input + history nav (v1.8.8) ─────────────────────────
        // These five were advertised in bridge.json + browser_describe but had no
        // router cases (found by the full verb self-test). Selector-based where it
        // helps; same session/tab auto-recovery as browser_eval.
        case 'browser_click': {
          if (!args.selector && !(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {
            sendJSON(res, { success: false, error: 'browser_click needs "selector" OR "x"+"y". Usage: { "sessionId": "...", "selector": "#submit" } or { "sessionId": "...", "x": 100, "y": 200 }' });
            return;
          }
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          try {
            if (args.selector) await withTimeout(tab.page.click(args.selector), 10_000, 'page.click');
            else await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y)), 10_000, 'mouse.click');
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, sessionId: resolvedId, tabId, _hint: 'Click dispatched (isTrusted, CDP-level). Verify the effect with browser_eval (e.g. document.title or a state probe) — a click that hit nothing still returns ok.' }) });
          } catch (e) {
            sendJSON(res, { success: false, errorCode: 'click_failed', error: `browser_click failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist/be visible. Probe it first: browser_eval {"expr":"!!document.querySelector('${args.selector}')"} — or click by x/y from a browser_screenshot.` : 'x/y click failed — coordinates are page-viewport pixels; take a browser_screenshot to aim.' });
          }
          return;
        }
        case 'browser_type': {
          if (typeof args.text !== 'string') {
            sendJSON(res, { success: false, error: 'browser_type needs "text" (string). Optional "selector" focuses the element first. Usage: { "sessionId": "...", "selector": "#q", "text": "hello" }' });
            return;
          }
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          try {
            if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');
            else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId, _hint: 'Typed via CDP (isTrusted). Verify with browser_eval reading the field value.' }) });
          } catch (e) {
            sendJSON(res, { success: false, errorCode: 'type_failed', error: `browser_type failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist. Probe: browser_eval {"expr":"!!document.querySelector('${args.selector}')"}` : 'No selector given — ensure an element has focus (browser_click it first).' });
          }
          return;
        }
        case 'browser_press_key': {
          if (!args.key) {
            sendJSON(res, { success: false, error: 'browser_press_key needs "key" — a puppeteer KeyInput name like "Enter", "Tab", "Escape", "ArrowDown", "a". Usage: { "sessionId": "...", "key": "Enter" }' });
            return;
          }
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          try {
            await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });
          } catch (e) {
            sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `browser_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });
          }
          return;
        }
        case 'browser_back':
        case 'browser_forward': {
          let session, resolvedId;
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          const dir = command === 'browser_back' ? 'back' : 'forward';
          try {
            let before = null; try { before = tab.page.url(); } catch {}
            // goBack/goForward resolve null on a CACHED history nav even though the URL
            // DID change, so we detect "moved" by the URL actually changing, not the
            // (unreliable) return value.
            await withTimeout(dir === 'back' ? tab.page.goBack({ waitUntil: 'domcontentloaded' }) : tab.page.goForward({ waitUntil: 'domcontentloaded' }), 20_000, 'history.' + dir).catch(() => {});
            let url = null; try { url = tab.page.url(); } catch {}
            const moved = !!(url && before && url !== before);
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, direction: dir, moved, url, sessionId: resolvedId, tabId, _hint: moved ? `Now at ${url}.` : `No ${dir === 'back' ? 'earlier' : 'later'} history entry — the page did not move (moved:false, not an error).` }) });
          } catch (e) {
            sendJSON(res, { success: false, errorCode: dir + '_failed', error: `browser_${dir} failed: ${e.message}` });
          }
          return;
        }
        case 'browser_input_dispatch': {
          // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.
          // Events fired here have isTrusted=true (Chromium dispatches them at the
          // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike
          // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false
          // and silently no-ops on framework click handlers / anti-automation gates.
          //
          // Types:
          //   click  — { x, y } coords, OR { selector } resolves bounding rect
          //   move   — { x, y } mouse move (useful for human-like trajectories)
          //   type   — { text } types into the focused element
          //   key    — { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }

          const t = (args.type || '').toLowerCase();
          if (!t) {
            sendJSON(res, { success: false, error: 'browser_input_dispatch: "type" required (click|move|type|key)' });
            return;
          }
          try {
            if (t === 'click') {
              if (args.selector) {
                // SMART SELECTOR PICK (v1.4.8+):
                //
                // Puppeteer's page.click(selector) blindly takes the first
                // document-order match. That bit users on WAGO when
                // `button.wg-button--primary` matched 4 elements (a 54x54
                // cookie-banner X with no text, two visible Add-to-cart
                // buttons, the actual Generate-datasheet button) — and the
                // cookie X got clicked. The relay returned ok:true because
                // a trusted click DID dispatch — just on the wrong element.
                //
                // We now run all matches through page.evaluate() to:
                //   1. Filter out hidden elements (display:none, opacity:0,
                //      offscreen, zero-area, behind the topmost stacking
                //      context modal)
                //   2. Filter out elements with empty text content (modal
                //      close buttons / icon-only utility buttons usually
                //      aren't what the caller wanted when they passed a
                //      class selector)
                //   3. Tie-break by visual area (largest wins — the actual
                //      submit button beats a small icon button)
                //
                // The chosen element's index, rect, and visible text are
                // returned in the response so the caller can sanity-check.
                // Pass `firstMatch:true` to skip filtering and use legacy
                // first-document-order behavior.
                const matches = await tab.page.$$(args.selector);
                if (matches.length === 0) {
                  sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (browser_wait) and the selector is correct (try with browser_eval first).' });
                  return;
                }

                let chosenHandle = matches[0];
                let chosenIndex = 0;
                let chosenMeta = null;
                // v1.4.10: surface which DOM element won the modal-root
                // detection in the response, so callers can debug false-
                // positives. Stays null in the single-match / firstMatch
                // branches since modal scoping doesn't apply there.
                let modalRootInfo = null;

                if (matches.length === 1 || args.firstMatch === true) {
                  // Single match OR explicit opt-out — use document-order first.
                  chosenMeta = await tab.page.evaluate((el) => {
                    if (!el) return null;
                    const r = el.getBoundingClientRect();
                    return {
                      x: Math.round(r.x), y: Math.round(r.y),
                      w: Math.round(r.width), h: Math.round(r.height),
                      text: (el.textContent || '').trim().slice(0, 80),
                    };
                  }, chosenHandle);
                } else {
                  // Multiple matches — score them and pick the best.
                  //
                  // v1.4.9+: when an open modal/dialog is on screen, restrict
                  // the candidate set to elements INSIDE it. The page behind
                  // a modal is visually inert; clicking elements behind a
                  // modal is almost never what the user intended. WAGO bit
                  // on this: their "Add to shopping cart" button (behind the
                  // modal) was 9648 px², the modal's "Generate data sheet"
                  // was 9264 px² — area-only tiebreak picked add-to-cart.
                  // Now we detect the modal root first and only look inside.
                  const evalResult = await tab.page.evaluate((selector) => {
                    // v1.4.10: A modal-root candidate is only "plausible" if it
                    // contains at least one visible interactive element with
                    // non-empty text. Without this filter, empty overlay
                    // containers (Vue-Toastification's 4 toast wrappers,
                    // notification mount points, full-screen ad scrims, etc.)
                    // win the heuristic — they're fixed/absolute, z ≥ 100, cover
                    // >25% of viewport — but contain no interactive content.
                    // Result before the fix: smart-pick scoped to the wrong
                    // root and missed the actual modal entirely (WAGO bug:
                    // pickStrategy ended up "visible-text-largest-no-modal-match"
                    // and clicked "Add to shopping cart" in the page behind
                    // the real Generate-Datasheet modal).
                    function isPlausibleModal(el) {
                      if (!el) return false;
                      const interactives = el.querySelectorAll(
                        'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'
                      );
                      for (const child of interactives) {
                        const cs = getComputedStyle(child);
                        if (cs.display === 'none' || cs.visibility === 'hidden') continue;
                        if (parseFloat(cs.opacity || '1') === 0) continue;
                        const r = child.getBoundingClientRect();
                        if (r.width === 0 || r.height === 0) continue;
                        const text = (child.textContent || '').trim();
                        if (text.length > 0) return true;
                      }
                      return false;
                    }

                    // Find the topmost modal/dialog root if one is open.
                    // Order matches what a human visually sees:
                    //   1. <dialog open>                    (HTML5 native)
                    //   2. [aria-modal="true"]              (ARIA)
                    //   3. role="dialog" + visible          (ARIA fallback)
                    //   4. fixed/absolute element with z-index ≥ 100
                    //      AND covering ≥25% of viewport    (custom modals)
                    //
                    // v1.4.10: every tier is now post-filtered through
                    // isPlausibleModal so we don't latch onto empty overlays.
                    // Each tier returns the most-recently-added (last in
                    // document order) plausible candidate, walking backward
                    // through matches if the topmost is implausible.
                    function topmostModalRoot() {
                      const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));
                      for (let i = openDialogs.length - 1; i >= 0; i--) {
                        if (isPlausibleModal(openDialogs[i])) return openDialogs[i];
                      }
                      const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))
                        .filter(el => {
                          const cs = getComputedStyle(el);
                          return cs.display !== 'none' && cs.visibility !== 'hidden';
                        });
                      for (let i = ariaModals.length - 1; i >= 0; i--) {
                        if (isPlausibleModal(ariaModals[i])) return ariaModals[i];
                      }
                      const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
                        .filter(el => {
                          const cs = getComputedStyle(el);
                          if (cs.display === 'none' || cs.visibility === 'hidden') return false;
                          const r = el.getBoundingClientRect();
                          return r.width > 0 && r.height > 0;
                        });
                      for (let i = dialogs.length - 1; i >= 0; i--) {
                        if (isPlausibleModal(dialogs[i])) return dialogs[i];
                      }
                      // Custom modals (no ARIA, just a styled overlay)
                      const candidates = Array.from(document.querySelectorAll('*'))
                        .filter(el => {
                          const cs = getComputedStyle(el);
                          if (!['fixed', 'absolute'].includes(cs.position)) return false;
                          const z = parseInt(cs.zIndex || '0', 10);
                          if (isNaN(z) || z < 100) return false;
                          const r = el.getBoundingClientRect();
                          const viewportArea = window.innerWidth * window.innerHeight;
                          if (viewportArea === 0) return false;
                          return (r.width * r.height) / viewportArea > 0.25;
                        })
                        .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));
                      for (const c of candidates) {
                        if (isPlausibleModal(c)) return c;
                      }
                      return null;
                    }
                    const modalRoot = topmostModalRoot();
                    const modalRootInfo = modalRoot ? {
                      tag: modalRoot.tagName.toLowerCase(),
                      id: modalRoot.id || null,
                      cls: ((modalRoot.className && typeof modalRoot.className === 'string')
                        ? modalRoot.className
                        : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),
                      z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),
                      role: modalRoot.getAttribute('role') || null,
                      ariaModal: modalRoot.getAttribute('aria-modal') || null,
                    } : null;

                    const els = Array.from(document.querySelectorAll(selector));
                    const scored = els.map((el, idx) => {
                      const r = el.getBoundingClientRect();
                      const cs = getComputedStyle(el);
                      const visible =
                        cs.display !== 'none' &&
                        cs.visibility !== 'hidden' &&
                        parseFloat(cs.opacity || '1') > 0 &&
                        r.width > 0 && r.height > 0 &&
                        r.bottom > 0 && r.right > 0 &&
                        r.top < (window.innerHeight || 99999) &&
                        r.left < (window.innerWidth || 99999);
                      const text = (el.textContent || '').trim();
                      const area = r.width * r.height;
                      const insideModal = modalRoot != null && modalRoot.contains(el);
                      return {
                        idx,
                        x: Math.round(r.x), y: Math.round(r.y),
                        w: Math.round(r.width), h: Math.round(r.height),
                        text: text.slice(0, 80),
                        hasText: text.length > 0,
                        visible,
                        area,
                        insideModal,
                        modalDetected: modalRoot != null,
                      };
                    });
                    return { scored, modalRoot: modalRootInfo };
                  }, args.selector);
                  const scored = evalResult.scored;
                  modalRootInfo = evalResult.modalRoot;

                  // Pick priority:
                  //   1. inside-modal AND visible AND has-text → largest area
                  //   2. visible AND has-text                  → largest area
                  //   3. visible (any)                         → largest area
                  //   4. document-order first match (last resort)
                  const modalDetected = scored.length > 0 && scored[0].modalDetected;
                  const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);
                  const visibleTexted = scored.filter(s => s.visible && s.hasText);
                  const visibleOnly = scored.filter(s => s.visible);
                  let pick = null;
                  let strategy = 'visible-text-largest';
                  if (inModal.length > 0) {
                    pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);
                    strategy = 'modal-scoped-largest';
                  } else if (visibleTexted.length > 0) {
                    pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);
                    strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';
                  } else if (visibleOnly.length > 0) {
                    pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);
                    strategy = 'visible-largest';
                  }
                  if (pick) {
                    chosenIndex = pick.idx;
                    chosenHandle = matches[chosenIndex];
                    chosenMeta = pick;
                    chosenMeta._strategy = strategy;
                  } else {
                    // Nothing visible at all — keep document-order first as last resort
                    // and surface the count so caller knows ambiguity exists.
                    chosenMeta = scored[0];
                    if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';
                  }
                }

                // page.click does scrollIntoView + bounding-rect resolution + page.mouse.click.
                // Chromium dispatches via Input.dispatchMouseEvent → isTrusted=true.
                await chosenHandle.click({
                  button: args.button || 'left',
                  clickCount: args.clickCount || 1,
                  delay: args.delay || 0,
                });
                sendJSON(res, { success: true, output: JSON.stringify({
                  ok: true,
                  sessionId: resolvedId,
                  tabId,
                  dispatched: 'click',
                  via: 'selector',
                  selector: args.selector,
                  matchedCount: matches.length,
                  chosenIndex,
                  clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,
                  clickedText: chosenMeta ? chosenMeta.text : null,
                  insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,
                  modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,
                  // v1.4.10: surface which DOM element won the modal-root
                  // detection so callers can debug false-positives. When the
                  // smart pick lands on the wrong element, this tells you
                  // immediately whether modal detection latched onto the
                  // intended overlay or got fooled by a toast-container etc.
                  modalRoot: modalRootInfo,
                  pickStrategy: matches.length === 1
                    ? 'only-match'
                    : (args.firstMatch === true
                      ? 'first-match'
                      : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),
                })});
                return;
              }
              if (typeof args.x !== 'number' || typeof args.y !== 'number') {
                sendJSON(res, { success: false, error: 'browser_input_dispatch click: provide either {selector} OR {x, y}' });
                return;
              }
              await tab.page.mouse.click(args.x, args.y, {
                button: args.button || 'left',
                clickCount: args.clickCount || 1,
                delay: args.delay || 0,
              });
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });
              return;
            }
            if (t === 'move') {
              if (typeof args.x !== 'number' || typeof args.y !== 'number') {
                sendJSON(res, { success: false, error: 'browser_input_dispatch move requires {x, y}' });
                return;
              }
              await tab.page.mouse.move(args.x, args.y, { steps: args.steps || 1 });
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });
              return;
            }
            if (t === 'type') {
              if (typeof args.text !== 'string') {
                sendJSON(res, { success: false, error: 'browser_input_dispatch type requires {text}' });
                return;
              }
              // Optionally focus a selector first so keystrokes land in the right input.
              if (args.selector) {
                try { await tab.page.focus(args.selector); }
                catch (e) {
                  sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });
                  return;
                }
              }
              await tab.page.keyboard.type(args.text, { delay: args.delay || 0 });
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });
              return;
            }
            if (t === 'key') {
              if (typeof args.key !== 'string') {
                sendJSON(res, { success: false, error: 'browser_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });
                return;
              }
              await tab.page.keyboard.press(args.key, { delay: args.delay || 0 });
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });
              return;
            }
            sendJSON(res, { success: false, error: `browser_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });
          } catch (e) {
            sendJSON(res, { success: false, error: `browser_input_dispatch ${t} failed: ${e.message}` });
          }
          return;
        }
        case 'browser_fetch_url': {
          // Fetch an arbitrary URL with the session's cookies and return the
          // raw bytes (base64 in JSON; CLI optionally writes to disk).
          //
          // Why this exists: when a vendor click spawns a popup tab whose
          // Content-Type is application/pdf, Chrome wraps it in the built-in
          // PDF viewer. In-page `fetch(location.href)` then returns the
          // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.
          // Bypass: re-issue the request via puppeteer's
          // BrowserContext.request — same cookies, no rendering layer, raw
          // response body. Works for any content type; particularly useful
          // for PDFs, ZIPs, CAD bundles, anything the page would otherwise
          // open in a viewer plugin.
          //
          // saveTo semantics (clarified in v1.4.9 — v1.4.8 was ambiguous):
          //   - This is the BRIDGE side (Windows desktop). The bridge ALWAYS
          //     returns bodyBase64. If the user wants the file on the Docker
          //     side (the common case), the CLI handles the write — see
          //     cli/src/commands.rs::browser_fetch_url. The arg name there
          //     is `saveTo` and it's interpreted as a Docker-side path.
          //   - For the rare case of writing directly on the DESKTOP
          //     filesystem (e.g. dropping a CAD bundle into the user's
          //     Downloads folder so they can open it locally), the CLI
          //     forwards `desktopSaveTo` here, which the bridge writes via
          //     fs.writeFileSync. Returns `desktopSavedTo` field so it's
          //     unambiguous which filesystem the path refers to.
          //
          // Args (bridge-facing):
          //   sessionId       (required)
          //   url             (required)
          //   method          (optional, default GET)
          //   headers         (optional, object — Cookie auto-included from session)
          //   body            (optional, raw string body for POST etc.)
          //   desktopSaveTo   (optional Windows path — write on desktop and
          //                   return desktopSavedTo. Always also returns
          //                   bodyBase64 so caller can verify or save
          //                   container-side too.)
          //   tabId           (optional — picks the cookie context; defaults
          //                   to active tab. Cookies are per-browser-context
          //                   so any tab in the session works.)
          if (!args.url) {
            sendJSON(res, { success: false, error: 'browser_fetch_url requires {url}' });
            return;
          }
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }

          // Helper: optionally write to the desktop filesystem; ALWAYS return
          // bodyBase64 so the CLI can either pass it through to the caller OR
          // base64-decode it and write to a Docker-side path.
          const respondWithBytes = (buf, ct, status) => {
            const out = {
              ok: true,
              bytes: buf.length,
              contentType: ct,
              status,
              sessionId: resolvedId,
              tabId,
              bodyBase64: buf.toString('base64'),
            };
            if (args.desktopSaveTo) {
              try {
                // Resolve the path explicitly so callers can see what was used.
                // Don't silently land at a quirky CWD-relative path like the
                // v1.4.8 saveTo bug did.
                const abs = path.resolve(args.desktopSaveTo);
                const dir = path.dirname(abs);
                fs.mkdirSync(dir, { recursive: true });
                fs.writeFileSync(abs, buf);
                out.desktopSavedTo = abs;
              } catch (e) {
                // Don't lose the bytes — return them in bodyBase64 plus an
                // explicit error so the CLI knows the desktop write failed
                // but the bytes are still recoverable container-side.
                out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;
                out.desktopSavedTo = null;
              }
            }
            sendJSON(res, { success: true, output: JSON.stringify(out) });
          };

          try {
            const browserCtx = tab.page.browserContext();
            const reqClient = browserCtx.request;
            if (!reqClient || typeof reqClient.fetch !== 'function') {
              // Fallback for older puppeteer: do an in-page fetch in a
              // separate clean tab (avoids PDF viewer interception by
              // navigating to about:blank first, then fetching).
              const cleanPage = await browserCtx.newPage();
              try {
                await cleanPage.goto('about:blank');
                const result = await cleanPage.evaluate(async (u, m, h, b) => {
                  const init = { method: m, credentials: 'include', headers: h || {} };
                  if (b != null) init.body = b;
                  const r = await fetch(u, init);
                  const ab = await r.arrayBuffer();
                  const u8 = new Uint8Array(ab);
                  // Convert in chunks — avoids stack overflow on large blobs
                  let bin = '';
                  const CHUNK = 32768;
                  for (let i = 0; i < u8.length; i += CHUNK) {
                    bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));
                  }
                  return {
                    base64: btoa(bin),
                    size: u8.length,
                    contentType: r.headers.get('content-type') || '',
                    status: r.status,
                  };
                }, args.url, args.method || 'GET', args.headers || null, args.body || null);
                await cleanPage.close({ runBeforeUnload: false });
                respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);
                return;
              } catch (e) {
                try { await cleanPage.close({ runBeforeUnload: false }); } catch {}
                throw e;
              }
            }

            // Modern puppeteer: BrowserContext.request.fetch — bypasses page
            // rendering entirely. Cookies from the context are auto-included.
            const init = {
              method: args.method || 'GET',
              headers: args.headers || {},
            };
            if (args.body != null) init.data = args.body;
            const resp = await reqClient.fetch(args.url, init);
            const buf = await resp.body();
            const ct = (resp.headers && resp.headers()['content-type']) || '';
            respondWithBytes(buf, ct, resp.status());
          } catch (e) {
            sendJSON(res, { success: false, error: `browser_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });
          }
          return;
        }
        case 'browser_errors': {
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          const errs = [...tab.errors];
          if (args.clear !== false) tab.errors.length = 0;
          sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });
          return;
        }
        case 'browser_reload': {
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          tab.errors.length = 0;
          await withTimeout(
            tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),
            30_000, 'page.reload'
          );
          // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.
          // Only raise if this session is one the user is actively watching.
          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });
          return;
        }
        case 'browser_navigate': {
          let session, resolvedId;
          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing
          // any error. The Docker container should never see "Session
          // closed" / "detached Frame" — those become internal retry
          // signals. See recoverOrRelaunchSession.
          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }
          catch (e) { sendSessionResolveError(res, e); return; }
          // v1.8.16: refuse a declared cross-owner navigate (window steal).
          { const refusal = ownershipRefusal(session, resolvedId, args, 'browser_navigate'); if (refusal) { sendJSON(res, refusal); return; } }
          let tab, tabId;
          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }
          catch (e) { sendTabResolveError(res, e); return; }
          // Apply stored basic-auth creds for the navigation target's host
          // BEFORE goto, so the Chrome native auth dialog never appears
          // for sites in the credential vault.
          try {
            const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);
            if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);
          } catch (e) {
            console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);
          }
          await withTimeout(
            tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),
            30_000, 'page.goto'
          );
          tab.errors.length = 0;
          // Re-apply title suffix after navigation
          try {
            const pName = session.profileName;
            // When there's more than one tab, include tabId in the title suffix
            const titleSuffix = session.tabs.length > 1
              ? ` (session: ${resolvedId} | ${tabId})`
              : (pName !== resolvedId
                  ? ` (session: ${resolvedId} | profile: ${pName})`
                  : ` (session: ${resolvedId})`);
            await tab.page.evaluate((suffix) => {
              document.title = document.title + suffix;
              const obs = new MutationObserver(() => {
                if (!document.title.endsWith(suffix)) document.title = document.title + suffix;
              });
              obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });
            }, titleSuffix);
          } catch {}
          // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus
          // on every navigate). Only raise for a session the user is actively watching.
          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }
          // Only persist URL for single-tab sessions — the session file is a
          // per-session snapshot and multi-tab URLs can't fit.
          if (session.tabs.length === 1) {
            saveSessionFile(resolvedId, session.profileName, args.url);
          }
          if (session && process.platform === 'win32') {
            setTimeout(() => { applyAppOverlay(session, resolvedId).catch(() => {}); }, 1500); // v1.8.92: new site, new favicon
            // v1.9.43: second pass — many sites inject their <link rel=icon> after first paint.
            setTimeout(() => { if (session) session._faviconRetried = false; applyAppOverlay(session, resolvedId).catch(() => {}); }, 4500);
            // v1.9.1: on an Adom nav, re-check auth (login may have completed since open) and
            // refresh the tab label so it becomes "🔓 Logged in" once actually authenticated.
            if (isAdomUrl(args.url)) {
              setTimeout(async () => {
                try {
                  const a = await checkWikiAuth(session.page);
                  await setWikiModeGlyph(session.page, a && a.authed ? '\u25CF Logged in' : '\u25CB Public');
                  await setWikiAuthedState(session, resolvedId, a && a.authed);
                } catch (e) {}
              }, 1800);
            }
            // v1.9.3: refresh the per-window wiki jump list (adds the toggle on a wiki page,
            // clears it when the window navigated off Adom).
            setTimeout(() => { updateWikiJumplist(session, resolvedId).catch(() => {}); }, 1000);
          }
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId, ...(authSteerHint(args.url) ? { _hint: authSteerHint(args.url).trim() } : {}) }) });
          return;
        }
        case 'browser_readiness': {
          // READ-ONLY readiness probe. NEVER triggers a download. `ready` reflects both
          // server-up (this verb answered) AND Chrome-for-Testing cached. For the general
          // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also
          // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.
          const rd = chrome.readiness();
          const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';
          const defTxt = rd.defaultBrowser
            ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`
            : ' Default: auto (first browser that launches).';
          const switchTxt = ' Switch/install with browser_use {browser:"chrome"|"edge"|"cft"|"auto"}.';
          sendJSON(res, {
            success: true,
            output: JSON.stringify(rd),
            ...rd,
            _hint: rd.ready
              ? `Ready — pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with browser_open_window.${switchTxt} RECOVERY NOTE: if you were polling this because pup's verbs were TIMING OUT (dim LED in AD / browser.bridgeRunning:false), that means the bridge PROCESS was down — a status probe like this does NOT respawn it; only a window/tab verb (browser_open_window) auto-spawns the bridge. And never SPAM browser_open_window while it says "bridge_starting" — concurrent opens on one profile used to collide (fixed v1.9.4, but still: fire ONE open, then poll browser_list_windows with backoff).`
              : rd.installing
                ? `Fetching Chrome for Testing (${rd.installProgressPct}%) — this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then browser_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`
                : rd.installPhase === 'failed'
                  ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry browser_prewarm (usually network/proxy blocking the download, or low disk).`
                  : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows — instant, no download), or call browser_use {browser:"cft"} / browser_prewarm to fetch Chrome for Testing.`,
          });
          return;
        }
        case 'browser_prewarm': {
          // Install Chrome for Testing WITHOUT opening a window — the warm-before-
          // first-use path. Default non-blocking so AD/HD can fire-and-forget on
          // embedded first-run; pass {wait:true} to block until installed.
          const block = !!(args && (args.wait === true || args.block === true));
          await chrome.ensureChromeReady({ background: !block });
          // Prewarm = the AI explicitly wants Chrome for Testing → pin it as the default
          // (unless a different browser was already explicitly pinned). Once cached, every
          // open drives CfT. Pass {setDefault:false} to only download without pinning.
          const pinCft = args && args.setDefault === false ? false : true;
          const curDef = chrome.readDefault();
          if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {
            chrome.setDefaultBrowser({ browser: 'cft' });
          }
          const rd = chrome.readiness();
          sendJSON(res, {
            success: rd.installPhase !== 'failed',
            output: JSON.stringify(rd),
            ...rd,
            statusVerb: 'browser_readiness',
            _hint: rd.ready
              ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} — pup will drive it on the next browser_open_window. (browser_use {browser:"auto"} reverts to installed Chrome/Edge.)`
              : rd.installing
                ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll browser_readiness until ready:true. No window is opened by prewarm.`
                : rd.installPhase === 'failed'
                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge — browser_use {browser:"auto"}.`
                  : 'Prewarm started; poll browser_readiness for completion.',
          });
          return;
        }
        case 'browser_use': {
          // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'
          // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome
          // for Testing in the background if not cached, then pins it. Persists across
          // bridge restarts. The launch path still spawn-verifies + falls back, so a
          // pinned browser that later breaks won't hard-fail an open.
          const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install), elevate: args ? args.elevate : undefined });
          const rd = chrome.readiness();
          const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';
          let hint;
          if (!result.ok) {
            hint = `${result.error} Available on this machine: ${availTxt}. Use browser_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;
          } else if (result.cleared) {
            hint = `Default cleared → AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next browser_open_window applies it.`;
          } else if (result.installingChrome) {
            hint = `Downloading & installing REAL Google Chrome (~90 MB, user-scoped) and pinning it as the default. On a normal PC this is SILENT (no prompt). On a locked-down box (VM / managed device) it needs a Windows approval — the bridge pops a native "click YES on the UAC" notification on the user's desktop, and if it expires it re-notifies with a one-tap "Approve now" (no AI turn needed). Poll browser_readiness: chromeStablePhase installing → awaiting_uac (waiting on the user's click; chromeStableAwaitingApproval:true) → ready. Until then pup drives ${availTxt}, so nothing is blocked. Pass {browser:"chrome", install:true, elevate:false} to skip the prompt path, or elevate:true to go straight to it.`;
          } else if (result.installing) {
            hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll browser_readiness until ready:true — until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;
          } else {
            hint = `Default pinned → ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next browser_open_window drives it. Available: ${availTxt}. Revert anytime with browser_use {browser:"auto"}.`;
          }
          sendJSON(res, {
            success: !!result.ok,
            output: JSON.stringify({ ...result, readiness: rd }),
            ...result,
            errorCode: result.ok ? undefined : (result.errorCode || 'browser_use_failed'),
            statusVerb: 'browser_readiness',
            _hint: hint,
          });
          return;
        }
        case 'browser_status': {
          const list = [];
          for (const [id, s] of sessions) {
            list.push(await getSessionInfo(id, s));
          }
          const statusResult = {
            ok: true,
            sessionCount: sessions.size,
            activeSession: activeSessionId,
            sessions: list,
            pupshotUrl,
          };
          // Add guidance when no sessions exist so the AI knows what to do next
          if (sessions.size === 0) {
            statusResult.hint = 'No browser sessions are open. Use browser_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';
          } else {
            // v1.8.37: nudge to close stale/piled-up windows (each row already has ageMinutes+owner).
            const cln = staleWindowsHint();
            if (cln) statusResult.hint = cln.trim();
          }
          sendJSON(res, { success: true, output: JSON.stringify(statusResult) });
          return;
        }
        case 'browser_close': {
          // With sessionId: close just that session. Without: close everything.
          // Per-session close mirrors browser_close_window's logic — kills the
          // Chrome process for that profile if no other sessions share it.
          if (args.sessionId) {
            const target = sessions.get(args.sessionId);
            if (!target) {
              sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });
              return;
            }
            // v1.8.16: refuse a declared cross-owner close.
            { const refusal = ownershipRefusal(target, args.sessionId, args, 'browser_close'); if (refusal) { sendJSON(res, refusal); return; } }
            const profileName = target.profileName;
            // Are there other sessions on this profile? If yes, only close
            // this session's tab (Chrome stays alive for the others). If no,
            // kill the Chrome process so the profile is fully released and
            // the next browser_open_window starts from a clean slate.
            // (Session map key IS the sessionId; iterate entries to compare.)
            const otherSessionsOnProfile = [...sessions.entries()].filter(
              ([id, s]) => id !== args.sessionId && s.profileName === profileName
            );
            try {
              if (otherSessionsOnProfile.length === 0) {
                // Only this session uses this Chrome — kill the whole browser.
                const be = browsers.get(profileName);
                if (be) {
                  await killBrowserProcess(be.browser, profileName);
                  browsers.delete(profileName);
                }
              } else {
                // Other sessions still using this Chrome — just close this tab.
                if (target.page) {
                  try { await target.page.close({ runBeforeUnload: false }); } catch {}
                }
              }
            } catch (e) {
              console.log(`browser_close (per-session) error: ${e.message}`);
            }
            sessions.delete(args.sessionId);
            deleteSessionFile(args.sessionId);
            if (activeSessionId === args.sessionId) {
              activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;
            }
            sendJSON(res, { success: true, output: JSON.stringify({
              ok: true,
              sessionId: args.sessionId,
              message: `Session "${args.sessionId}" closed`,
              chromeKilled: otherSessionsOnProfile.length === 0,
              remainingSessions: sessions.size,
            })});
            return;
          }

          // No sessionId — close everything (also the taskbar jump-list "Close ALL" task).
          for (const [pName, be] of browsers) {
            await killBrowserProcess(be.browser, pName);
          }
          browsers.clear();
          // v1.9.51: unregister every per-session AUMID as we drop the sessions, so closing all
          // windows leaves NO stale taskbar identity behind (the exact "old icon" class John flagged).
          for (const [id, s] of sessions) {
            deleteSessionFile(id);
            clearSessionBusy(id);                            // v1.9.57
            try { if (s && s._curAppId && s._curAppId !== PUP_APP_ID) unregisterPupAumid(s._curAppId).catch(() => {}); } catch (e) {}
          }
          sessions.clear();
          activeSessionId = null;
          // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.
          try { pruneStaleAumids(); } catch (e) {}
          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true,
            message: 'All sessions and browsers closed',
            remainingSessions: 0,
          })});
          return;
        }
        case 'browser_wait': {
          const ms = args.ms || 3000;
          await new Promise(r => setTimeout(r, ms));
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });
          return;
        }

        // ─── Credential vault (HTTP Basic Auth for pup) ────────────────
        // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,
        // libsecret on Linux). Applied via puppeteer's page.authenticate()
        // before navigation so Chrome's native auth dialog never appears.
        case 'credential_set': {
          try {
            const r = await credentialVault.setCredential({
              host: args.host,
              username: args.username,
              password: args.password,
            });
            sendJSON(res, { success: true, output: JSON.stringify(r) });
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'credential_list': {
          try {
            const credentials = credentialVault.listCredentials();
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials }) });
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'credential_delete': {
          try {
            const r = await credentialVault.deleteCredential(args.host);
            sendJSON(res, { success: true, output: JSON.stringify(r) });
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }

        // ─── Desktop recording (HUD) ───────────────────────────────────
        case 'desktop_recorder_open': {
          try {
            await ensureRecorderWindowOpen(args.reason);
            sendJSON(res, { success: true, output: JSON.stringify({
              ok: true, opened: true, reason: desktopRecorder.reason,
            })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_record_start': {
          try {
            const r = await desktopRecordStartImpl(args);
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_record_stop': {
          try {
            const r = await desktopRecordStopImpl(args);
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_record_status': {
          const cur = desktopRecorder.current;
          const status = {
            ok: true,
            hudOpen: !!desktopRecorder.browser,
            reason: desktopRecorder.reason,
            currentRecording: cur ? {
              recordingId: cur.recordingId,
              durationMs: Date.now() - cur.startedAt,
              filePath: cur.filePath,
              fps: cur.fps,
              audio: cur.audio,
            } : null,
            clipsThisSession: desktopRecorder.clipsThisSession,
          };
          sendJSON(res, { success: true, output: JSON.stringify(status) });
          return;
        }
        case 'desktop_record_list': {
          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true, recordings: desktopRecordListImpl(),
          })});
          return;
        }
        case 'desktop_recorder_close': {
          try {
            const summary = await closeRecorderWindow();
            sendJSON(res, { success: true, output: JSON.stringify({
              ok: true, sessionSummary: summary,
            })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_list_monitors': {
          // Minimal v1 — return primary monitor only, with best-effort dimensions
          // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)
          const monitors = [{
            index: 0, name: 'Primary',
            x: 0, y: 0,
            width: 1920, height: 1080, // best-effort fallback
            primary: true,
          }];
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});
          return;
        }

        // ─── Tab recording (CDP screencast, no HUD) ─────────────────────
        case 'browser_record_start': {
          try {
            const sessionId = args.sessionId || activeSessionId;
            if (!sessionId) {
              sendJSON(res, { success: false, error: 'sessionId is required' });
              return;
            }
            const r = await tabRecordStartImpl({ ...args, sessionId });
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'browser_record_stop': {
          try {
            if (!args.recordingId) {
              sendJSON(res, { success: false, error: 'recordingId is required' });
              return;
            }
            const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'browser_record_status': {
          const active = tabRecordStatusImpl({ sessionId: args.sessionId });
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});
          return;
        }
        case 'browser_record_list': {
          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true, recordings: tabRecordListImpl(),
          })});
          return;
        }

        default:
          sendJSON(res, { success: false, error: `Unknown command: ${command}. Valid commands: browser_open_window, browser_switch_window, browser_list_windows, browser_close_window, browser_alert_window, browser_focus_window, browser_open_tab, browser_switch_tab, browser_close_tab, browser_list_tabs, browser_screenshot, browser_screenshot_full_res, browser_eval, browser_errors, browser_reload, browser_navigate, browser_status, browser_configure, browser_close, browser_wait, desktop_recorder_open, desktop_record_start, desktop_record_stop, desktop_record_status, desktop_record_list, desktop_recorder_close, desktop_list_monitors, browser_record_start, browser_record_stop, browser_record_status, browser_record_list. Tab-aware commands (screenshot, eval, errors, reload, navigate) accept an optional "tabId" to target a specific tab.` }, 404);
          return;
      }
    }

    // Legacy HTTP endpoints (for direct curl access) — operate on active session

    // POST /launch
    if (pathname === '/launch' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      if (body.pupshotUrl) pupshotUrl = body.pupshotUrl;
      const session = await launchSession(body.sessionId || 'default', body.url);
      session.errors.length = 0;
      sendJSON(res, { ok: true, url: body.url, sessionId: activeSessionId });
      return;
    }

    // POST /navigate
    if (pathname === '/navigate' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      await session.page.goto(body.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
      session.errors.length = 0;
      sendJSON(res, { ok: true, url: body.url });
      return;
    }

    // POST /reload
    if (pathname === '/reload' && req.method === 'POST') {
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      session.errors.length = 0;
      await session.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
      sendJSON(res, { ok: true });
      return;
    }

    // GET /screenshot
    if (pathname === '/screenshot') {
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      const ts = Date.now();
      const fp = url.searchParams.get('fullPage') === 'true';
      let shotBuf = await session.page.screenshot({ type: 'png', fullPage: fp });
      // Downscale when sharp is available; else serve at capture size (sharp optional).
      const shot = await processScreenshot(shotBuf, MAX_SCREENSHOT_DIM);
      shotBuf = shot.buf;
      const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);
      fs.writeFileSync(filePath, shotBuf);
      const sizeKB = Math.round(shotBuf.length / 1024);
      session.latestShot = filePath;

      let container = null;
      if (pupshotUrl) {
        try {
          const buf = fs.readFileSync(filePath);
          const b64 = buf.toString('base64');
          const postBody = JSON.stringify({ image: 'data:image/png;base64,' + b64 });
          const resp = await fetch(pupshotUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: postBody,
          });
          container = await resp.json();
        } catch (e) {
          container = { error: e.message };
        }
      }

      sendJSON(res, {
        ok: true,
        sizeKB,
        filePath,
        width: shot.width,
        height: shot.height,
        origWidth: shot.origWidth,
        origHeight: shot.origHeight,
        resized: shot.resized,
        container,
      });
      return;
    }

    // GET /screenshot.png (serves latest shot regardless of format)
    if (pathname === '/screenshot.png') {
      const session = getActiveSession();
      const shot = session?.latestShot;
      if (shot && fs.existsSync(shot)) {
        const buf = fs.readFileSync(shot);
        res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*' });
        res.end(buf);
      } else {
        res.writeHead(404);
        res.end('No screenshot yet');
      }
      return;
    }

    // GET /errors
    if (pathname === '/errors') {
      const session = getActiveSession();
      const errs = session ? session.errors : [];
      sendJSON(res, { errors: errs, count: errs.length });
      return;
    }

    // POST /eval
    if (pathname === '/eval' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      const expr = String(body.expr || '');
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      let result;
      try {
        result = await session.page.evaluate(new Function('return (' + expr + ')'));
      } catch (e) {
        result = { __error: e.message };
      }
      sendJSON(res, { ok: true, result });
      return;
    }

    // GET /wait?ms=N
    if (pathname === '/wait') {
      const ms = parseInt(url.searchParams.get('ms') || '3000');
      await new Promise(r => setTimeout(r, ms));
      sendJSON(res, { ok: true, waited: ms });
      return;
    }

    // GET /status
    if (pathname === '/status') {
      const list = [];
      for (const [id, s] of sessions) {
        let pageUrl = null;
        try { pageUrl = s.page.url(); } catch (e) {}
        list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });
      }
      // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip
      // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is
      // missing/installing, red if its install failed, green when pup is ready.
      const rd = chrome.readiness();
      let led = 'green', summary, tooltip;
      if (rd.installPhase === 'failed') {
        led = 'red'; summary = 'Chrome install failed';
        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry browser_prewarm (usually a network/proxy or disk issue).`;
      } else if (rd.installing) {
        led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;
        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) — pup not ready yet.\nPoll browser_readiness until ready.`;
      } else if (!rd.ready) {
        // No drivable browser AT ALL (no Chrome, no Edge, no cached CfT) — the rare case.
        led = 'yellow'; summary = 'No browser yet';
        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nNo Chrome/Edge detected and Chrome for Testing not cached — pup will fetch CfT on first use (or call browser_prewarm). Installing Chrome or Edge is instant, no download.`;
      } else {
        // READY: a drivable browser exists (installed Chrome/Edge, or cached CfT).
        // Installed-browser-first model — CfT being absent is NOT a warning state.
        led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'} · ${rd.browserKind}`;
        tooltip = `Puppeteer bridge v${BRIDGE_VERSION} — ready\nDrives ${rd.browserKind} (${rd.browserSource}) · ${sessions.size} session(s)${rd.chromeForTestingInstalled ? `\nCfT ${rd.chromeBuildId || ''} cached as fallback` : ''}`;
      }
      // v1.8.78: the ONE non-cosmetic degradation on old AD gets a visible chip warning.
      if (_adRaiseGateBypassed) {
        if (led === 'green') led = 'yellow';
        tooltip += `\n⚠ AD ${_adVersion} < 1.9.124: raise intercepted — the foreground gate does not apply on this box. Update Adom Desktop.`;
      }
      sendJSON(res, {
        ok: true,
        bridge: 'puppeteer',
        version: BRIDGE_VERSION,
        adVersion: _adVersion,
        raiseGateBypassed: _adRaiseGateBypassed || undefined,
        led, summary, tooltip,
        sessionCount: sessions.size,
        activeSession: activeSessionId,
        sessions: list,
        chrome: rd,
        pupshotUrl,
      });
      return;
    }

    // POST /close
    if (pathname === '/close' && req.method === 'POST') {
      for (const [, be] of browsers) {
        try { await be.browser.close(); } catch (e) {}
      }
      browsers.clear();
      sessions.clear();
      activeSessionId = null;
      sendJSON(res, { ok: true, message: 'All sessions closed' });
      return;
    }

    sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);
  } catch (e) {
    console.error('Handler error:', e);
    sendJSON(res, { error: e.message }, 500);
  }
});

// Global request timeout — no HTTP request should hang longer than 45s
server.timeout = 45_000;
server.keepAliveTimeout = 30_000;

// Bind to the host AD tells us to (AD 1.9.63+ passes ADOM_BIND_HOST, always 127.0.0.1),
// falling back to loopback. AD's hard guarantee is NO firewall prompts by default —
// nothing it spawns may bind a public interface (0.0.0.0) unasked. A hostless listen()
// binds every interface and pops the Windows Firewall "allow access?" dialog; honoring
// ADOM_BIND_HOST keeps a loopback-only socket, which is firewall-exempt.
const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1';
server.listen(PORT, BIND_HOST, async () => {
  console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);
  console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');
  console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');
  // Self-warm: detect browsers + disk the moment the bridge spawns (right after AD
  // installs/updates it), so the FIRST open is instant. Only fetches CfT if the box
  // has literally no Chrome/Edge and no cached CfT — otherwise it's a cheap no-op.
  try { chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }
  // v1.8.73: register the Adom.Pup app identity once per bridge run (HKCU AUMID + Start Menu
  // shortcut whose pin launches a managed pup window). Loopback call from the bridge itself —
  // trusted transport, no relay gating. Self-gates on AD <1.9.134 (unknown verb).
  setTimeout(() => { ensurePupIdentityRegistered().catch(() => {}); }, 2500);
  setTimeout(() => { probeAdVersion().catch(() => {}); }, 1500);
  setTimeout(() => { brandSweep('startup').catch(() => {}); }, 8000);
  setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000);

  // Recover sessions from disk (reconnect to Chrome windows that survived bridge restart)
  try {
    await recoverSessions();
  } catch (e) {
    console.error('Session recovery failed:', e.message);
  }

  // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked
  // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),
  // "not a live session" matches EVERY key and the sweep deletes the whole set, unbranding windows
  // that were about to be re-stamped (observed: "pruned 12 stale keys" then keys: 0). Wait for the
  // session map to settle, and never sweep from an empty map — with no live sessions there is
  // nothing to distinguish stale from pending, so doing nothing is the correct, safe answer.
  setTimeout(() => {
    try {
      if (!sessions.size) { console.log('[identity] AUMID prune skipped — no live sessions to compare against'); return; }
      pruneStaleAumids();
    } catch (e) {}
  }, 60000);

  // v1.9.47: reclaim on boot (catches windows orphaned by a crash/restart) and then as a slow safety
  // net, so an unbranded window can never persist even if its window-opened event was missed.
  setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);
  setInterval(() => { reclaimUnmanagedWindows().catch(() => {}); }, 90000);

  // Periodic cleanup: every 5 minutes, remove stale session files with dead PIDs
  setInterval(async () => {
    const files = listSessionFiles();
    for (const sf of files) {
      if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {
        console.log(`Cleanup: removing stale session file "${sf.sessionId}" (PID ${sf.pid} dead)`);
        deleteSessionFile(sf.sessionId);
      }
    }
  }, 5 * 60 * 1000);
});

// ════════════════════════════════════════════════════════════════════════════
//  RECORDING — desktop (HUD) and tab (CDP screencast)
// ════════════════════════════════════════════════════════════════════════════

const RECORDINGS_DIR = path.join(__dirname, 'recordings');
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');

// ─── ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ──
// Probe at module-load so we know upfront whether the remux path works.
// Cached for the life of the process. Try common Windows install dirs in
// addition to PATH so a winget/scoop/choco install is found even if PATH
// hasn't been refreshed in the bridge's environment.
let FFMPEG_PATH = null;
// v1.8.20: re-callable (was a boot-only IIFE). Recording verbs call it LAZILY when
// FFMPEG_PATH is still null, so a user who installs ffmpeg AFTER the bridge started can
// record immediately — no bridge restart needed. Re-scans cleanly each call.
function detectFFmpeg() {
  FFMPEG_PATH = null;
  const { spawnSync } = require('child_process');
  // 1. PATH
  try {
    const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });
    if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }
  } catch {}
  // 2. Common Windows install paths
  if (process.platform === 'win32') {
    const candidates = [
      'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
      'C:\\ffmpeg\\bin\\ffmpeg.exe',
      'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',
      `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,
      `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,
    ];
    for (const p of candidates) {
      try {
        if (fs.existsSync(p)) {
          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });
          if (r.status === 0) { FFMPEG_PATH = p; return; }
        }
      } catch {}
    }
    // 3. Glob the WinGet directory (version number changes between releases)
    try {
      const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;
      if (fs.existsSync(winget)) {
        for (const e of fs.readdirSync(winget)) {
          if (e.startsWith('Gyan.FFmpeg')) {
            const sub = path.join(winget, e);
            const walk = (dir) => {
              for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
                if (f.isDirectory()) {
                  const r = walk(path.join(dir, f.name));
                  if (r) return r;
                } else if (f.name === 'ffmpeg.exe') {
                  return path.join(dir, f.name);
                }
              }
              return null;
            };
            const found = walk(sub);
            if (found) { FFMPEG_PATH = found; return; }
          }
        }
      }
    } catch {}
  }
  return FFMPEG_PATH;
}
detectFFmpeg();
if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);
else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via 'winget install Gyan.FFmpeg' to enable Duration metadata.`);

/**
 * Remux a WebM file in place to add a SegmentDuration tag.
 * Uses `ffmpeg -i in.webm -c copy out.webm` — copies the stream packets
 * unchanged (no re-encode), and the matroska muxer writes a complete
 * SegmentInfo with Duration. Fast (~100-300ms for typical clips).
 * Atomic: writes to a temp file then renames so a partial run can't
 * corrupt the original.
 */
async function remuxWebmAddDuration(filePath) {
  if (!FFMPEG_PATH) detectFFmpeg();   // lazy re-scan: ffmpeg may have been installed since boot
  if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');
  const { spawn } = require('child_process');
  const tmpPath = filePath + '.remux.tmp';
  await new Promise((resolve, reject) => {
    const proc = spawn(FFMPEG_PATH, [
      '-y',                       // overwrite tmp if it exists
      '-loglevel', 'error',       // quiet but still surface errors
      '-i', filePath,
      '-c', 'copy',               // no re-encode — just rewrite container
      '-f', 'webm',
      tmpPath,
    ], { stdio: ['ignore', 'ignore', 'pipe'] });
    let stderr = '';
    proc.stderr.on('data', b => { stderr += b.toString(); });
    proc.on('error', reject);
    proc.on('exit', code => {
      if (code === 0) resolve();
      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));
    });
  });
  // Atomic replace: rename tmp → original. fs.renameSync replaces on Win10+.
  fs.renameSync(tmpPath, filePath);
}

// Desktop recorder (singleton; one HUD across the whole bridge)
const desktopRecorder = {
  browser: null,
  page: null,
  windowId: null,           // Chrome windowId for Browser.setWindowBounds
  cdp: null,                // CDP session to recorder page
  reason: null,
  hudOpenedAt: null,
  current: null,            // active recording state, see below
  clipsThisSession: [],     // [{recordingId, filePath, sizeKB, durationMs, reason}]
  recCounter: 0,
  idleTimer: null,
};

// Tab recordings (concurrent; many at once)
// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,
//                  cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>
const tabRecordings = new Map();
let tabRecCounter = 0;

// ─── Desktop recorder helpers ───────────────────────────────────────────────

function recorderHtmlUrl(reason) {
  const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');
  const params = new URLSearchParams({ reason: reason || '' }).toString();
  return `file:///${file}?${params}`;
}

async function ensureRecorderWindowOpen(reason) {
  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
    throw new Error('reason is required — explain to the user why you\'re recording');
  }
  // If browser+page exist and are alive, just update the reason
  if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {
    try {
      await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);
      desktopRecorder.reason = reason;
      return;
    } catch {
      // Page died; tear down and respawn
      try { await desktopRecorder.browser.close(); } catch {}
      desktopRecorder.browser = null;
      desktopRecorder.page = null;
      desktopRecorder.cdp = null;
    }
  }

  fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });

  // Single fixed size for the HUD — large enough that every state's content
  // fits without dynamic resizing. Position is set AFTER launch via CDP using
  // screen.availWidth/availHeight (taskbar-aware, DPI-aware) — Chrome's
  // --window-position arg uses physical pixels and doesn't account for the
  // taskbar, which made the HUD half off-screen on non-1920×1080 displays.
  const winW = 360, winH = 420;

  console.log(`[recorder] Spawning HUD window (reason="${reason}")`);

  const launchArgs = [
    `--app=${recorderHtmlUrl(reason)}`,
    `--window-size=${winW},${winH}`,
    // No --window-position; we anchor bottom-right after the page loads
    '--use-fake-ui-for-media-stream',
    '--auto-select-desktop-capture-source=Entire screen',
    '--enable-features=GetDisplayMediaSet',
    '--auto-accept-this-tab-capture',
    '--no-sandbox',
    '--no-first-run',
    '--no-default-browser-check',
    '--disable-features=Translate',
    `--user-data-dir=${RECORDER_PROFILE_DIR}`,
  ];

  desktopRecorder.browser = await puppeteer.launch({
    headless: false,
    defaultViewport: null,
    ignoreDefaultArgs: ['--enable-automation'],
    args: launchArgs,
  });

  // Find the recorder page (the only page in this Chrome)
  const deadline = Date.now() + 5000;
  while (Date.now() < deadline) {
    const pages = await desktopRecorder.browser.pages();
    if (pages.length > 0) {
      desktopRecorder.page = pages[0];
      break;
    }
    await new Promise(r => setTimeout(r, 50));
  }
  if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');

  await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });

  // ── Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ──
  // Chrome's --window-position uses physical pixels and ignores the taskbar,
  // so a hardcoded fallback can land the HUD off-screen on any monitor that
  // isn't 1920×1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)
  // and use CDP Browser.setWindowBounds to position it precisely.
  try {
    const cdp = await desktopRecorder.page.target().createCDPSession();
    desktopRecorder.cdp = cdp;
    const wInfo = await cdp.send('Browser.getWindowForTarget', {});
    desktopRecorder.windowId = wInfo.windowId;

    const dims = await desktopRecorder.page.evaluate(() => ({
      availLeft: screen.availLeft || 0,
      availTop: screen.availTop || 0,
      availWidth: screen.availWidth,
      availHeight: screen.availHeight,
      outerWidth: window.outerWidth,
      outerHeight: window.outerHeight,
    }));
    const margin = 16;
    const winW = Math.min(dims.outerWidth, dims.availWidth);
    const winH = Math.min(dims.outerHeight, dims.availHeight);
    const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);
    const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);
    await cdp.send('Browser.setWindowBounds', {
      windowId: wInfo.windowId,
      bounds: { left, top, width: winW, height: winH, windowState: 'normal' },
    });
    console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);
  } catch (e) {
    console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);
  }

  // Wire exposed functions
  await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {
    if (!desktopRecorder.current || !desktopRecorder.current.fd) return;
    try {
      fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));
    } catch (e) {
      console.error(`[recorder] saveChunk failed: ${e.message}`);
    }
  });

  await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {
    const cur = desktopRecorder.current;
    if (!cur) return;
    try { fs.closeSync(cur.fd); } catch {}
    cur.fd = null;

    // ── Bug 3a fix: ffmpeg remux to write SegmentDuration ──
    // MediaRecorder writes a "live" WebM with no Duration element, so
    // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML
    // patching corrupted the Tracks element / SeekHead offsets and made
    // the resulting files unplayable (nb_streams=0). Ditched.
    //
    // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` — copies
    // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes
    // a proper SegmentInfo with Duration. We do this asynchronously so
    // the UI's "Saved" state appears immediately; the patched file is
    // ready by the time the user pulls it.
    //
    // If ffmpeg isn't on PATH we leave the file untouched. It's still a
    // valid (playable) WebM — just no Duration tag in the container.
    // Telemetry: log clearly so the user knows whether it ran.
    try {
      await remuxWebmAddDuration(cur.filePath);
      console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);
    } catch (e) {
      console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);
    }

    let sizeKB = 0;
    try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}
    cur.durationMs = durationMs;
    cur.sizeKB = sizeKB;
    cur.stoppedAt = Date.now();
    // Write sidecar metadata
    try {
      const sidecar = {
        recordingId: cur.recordingId,
        filePath: cur.filePath,
        startedAt: cur.startedAt,
        stoppedAt: cur.stoppedAt,
        durationMs,
        sizeKB,
        sizeBytes: sentBytes,
        fps: cur.fps,
        audio: cur.audio,
        monitor: cur.monitor,
        reason: cur.reason,
        stopReason: cur.stopReason || 'requested',
      };
      fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));
    } catch (e) {
      console.error(`[recorder] sidecar write failed: ${e.message}`);
    }
    // Add to session clip list
    desktopRecorder.clipsThisSession.push({
      recordingId: cur.recordingId, filePath: cur.filePath,
      sizeKB, durationMs, reason: cur.reason,
    });
    desktopRecorder.current = null;
    console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);
    // Tell page to show Saved state
    try {
      await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {
        filename: path.basename(cur.filePath),
        sizeBytes: sentBytes,
        durationMs,
      });
    } catch {}
    // Resolve any waiter
    if (cur.stopResolver) cur.stopResolver();
  });

  await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {
    try {
      const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());
      const target = desktopRecorder.page.target();
      const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort
      // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP
      const { windowId } = await cdp.send('Browser.getWindowForTarget', {});
      desktopRecorder.windowId = windowId;
      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });
    } catch (e) {
      console.log(`[recorder] resize failed (non-fatal): ${e.message}`);
    }
  });

  await desktopRecorder.page.exposeFunction('_recorderClosed', () => {
    console.log(`[recorder] Page reports closing`);
  });

  await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {
    try {
      execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });
    } catch {}
  });

  // Detect window-close as a stop signal
  desktopRecorder.browser.on('disconnected', () => {
    console.log(`[recorder] Browser disconnected`);
    if (desktopRecorder.current) {
      // Best-effort: mark stopped, write whatever we have
      try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}
      if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();
      desktopRecorder.current = null;
    }
    desktopRecorder.browser = null;
    desktopRecorder.page = null;
    desktopRecorder.cdp = null;
    desktopRecorder.reason = null;
    desktopRecorder.clipsThisSession = [];
  });

  desktopRecorder.reason = reason;
  desktopRecorder.hudOpenedAt = Date.now();
  desktopRecorder.clipsThisSession = [];
}

async function closeRecorderWindow() {
  if (desktopRecorder.current) {
    // Stop active recording first
    try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}
    // Wait briefly for stop to flush
    const deadline = Date.now() + 3000;
    while (desktopRecorder.current && Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 50));
    }
  }
  const summary = {
    clipCount: desktopRecorder.clipsThisSession.length,
    totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),
    reason: desktopRecorder.reason,
    clips: desktopRecorder.clipsThisSession,
  };
  if (desktopRecorder.browser) {
    try { await desktopRecorder.browser.close(); } catch {}
  }
  desktopRecorder.browser = null;
  desktopRecorder.page = null;
  desktopRecorder.cdp = null;
  desktopRecorder.reason = null;
  desktopRecorder.current = null;
  desktopRecorder.clipsThisSession = [];
  return summary;
}

async function desktopRecordStartImpl(opts) {
  const { reason, monitor = 'primary', fps = 30, audio = false,
          videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,
          confirmDesktopNotTabRecording } = opts;

  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
    throw new Error('reason is required — explain to the user why you\'re recording');
  }

  // Guardrail: Claude frequently mistakes "record this pup window/tab" for a
  // desktop-recording request. Desktop recording captures the WHOLE screen
  // (including unrelated apps in the foreground); if the pup window is
  // occluded the resulting clip won't show what the user actually wanted.
  // For 95% of "record a pup" asks the right verb is browser_record_start
  // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).
  // Adom-desktop's full-desktop recording is mostly redundant with what
  // Hydrogen ships natively, and only really useful when Hydrogen is
  // already busy doing tab-recording for ralph-loop screenshot tests AND
  // the user wants a parallel desktop video at the same time.
  // Force the caller to explicitly opt in so they re-read this and confirm.
  if (confirmDesktopNotTabRecording !== true) {
    const err = new Error(
      'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' +
      'If the user said "record a pup" / "record this Chrome window" / "record the page", ' +
      'they almost certainly want browser_record_start instead — that is tab-scoped via ' +
      'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' +
      'returns a single .webm at ~50fps, and does not need a HUD. ' +
      'Hydrogen also has built-in desktop recording (recording start --share screen) which ' +
      'is usually preferable for general "record my screen" asks. ' +
      'adom-desktop\'s desktop_record_* is mainly for the case where Hydrogen is already ' +
      'doing tab recording (ralph-loop screenshot tests) and you want a parallel desktop ' +
      'video at the same time. ' +
      'If you have read this and you really do want full-desktop recording (not pup tab), ' +
      'pass confirmDesktopNotTabRecording: true and resend the call.'
    );
    err.code = 'desktop_record_needs_confirmation';
    err.hint = 'For pup tab recording: adom-desktop browser_record_start \'{"sessionId":"<id>","fps":30}\'. ' +
               'For Hydrogen-driven desktop recording: hydrogen recording start --share screen. ' +
               'For a parallel desktop clip alongside Hydrogen tab capture: re-call with confirmDesktopNotTabRecording: true.';
    throw err;
  }

  if (desktopRecorder.current && !desktopRecorder.current.stopping) {
    throw new Error(`A recording is already in progress (${desktopRecorder.current.recordingId}). Stop it first.`);
  }

  await ensureRecorderWindowOpen(reason);

  const recordingId = `rec-${++desktopRecorder.recCounter}`;
  const ts = Date.now();
  const filePath = path.join(RECORDINGS_DIR, `rec-desktop-${ts}.webm`);
  const fd = fs.openSync(filePath, 'w');

  desktopRecorder.current = {
    recordingId, filePath, fd,
    startedAt: ts, stoppedAt: null,
    durationMs: 0, sizeKB: 0,
    fps, audio, monitor, reason,
    stopping: false, stopResolver: null,
    stopReason: null,
  };

  // Safety cap
  desktopRecorder.current.maxDurationTimer = setTimeout(async () => {
    if (desktopRecorder.current && desktopRecorder.current.recordingId === recordingId) {
      desktopRecorder.current.stopReason = 'maxDuration';
      try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}
    }
  }, maxDurationMs);

  // Tell the page to start recording
  await desktopRecorder.page.evaluate((startOpts) => window.startRecording(startOpts), {
    fps, audio, videoBitsPerSecond, reason,
  });

  return {
    recordingId, filePath, startedAt: ts,
    monitor: { name: monitor }, reason,
  };
}

async function desktopRecordStopImpl({ recordingId } = {}) {
  if (!desktopRecorder.current) throw new Error('No active desktop recording');
  const cur = desktopRecorder.current;
  if (recordingId && cur.recordingId !== recordingId) {
    throw new Error(`Recording ${recordingId} not active (current is ${cur.recordingId})`);
  }
  if (cur.stopping) throw new Error('Already stopping');
  cur.stopping = true;
  if (cur.maxDurationTimer) clearTimeout(cur.maxDurationTimer);

  const stopWait = new Promise(r => { cur.stopResolver = r; });
  try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch (e) {
    // If evaluate fails (e.g. page died), we still resolve below via disconnected handler
    console.log(`[recorder] page.stopRecording threw: ${e.message}`);
  }
  // Wait for _recordingStopped exposed function to fire (with timeout)
  await Promise.race([stopWait, new Promise(r => setTimeout(r, 10000))]);

  const stopReason = cur.stopReason || 'requested';
  return {
    recordingId: cur.recordingId,
    filePath: cur.filePath,
    sizeKB: cur.sizeKB,
    durationMs: cur.durationMs,
    stopReason,
  };
}

// ─── Tab recording (CDP screencast) ────────────────────────────────────────

// ── Tab recording via CDP Page.startScreencast → ffmpeg pipe ──────────────
//
// The previous incarnation of this code (v1.3.32-attempt) tried Chrome's
// in-tab MediaRecorder + getDisplayMedia({preferCurrentTab:true}). That
// path turned out to be unreliable for tab scoping: Chromium's
// --use-fake-ui-for-media-stream defaults to "Entire Screen" capture, and
// --auto-select-tab-capture-source-by-title scans across ALL Chrome
// processes (it picked the user's other Chrome windows instead of pup's).
// No flag combination reliably scoped the capture to the calling tab.
//
// CDP Page.startScreencast IS reliably tab-scoped at the protocol level —
// the CDP session is bound to a specific Page target, and screencastFrame
// only emits frames for that Page. We pipe those JPEG frames directly into
// ffmpeg's stdin (no per-frame disk write, no tar bundle). ffmpeg encodes
// VP9 in real time and produces a single .webm file. ffmpeg is already
// installed (FFMPEG_PATH probed at module load; same dep as the desktop
// recorder's duration-remux step).
//
// Caller note: Chrome throttles paint on occluded windows, so call
// browser_raise_os_window first if you want frames at the requested fps.

// Brief pause to let any in-flight Page.captureScreenshot settle before
// detaching the CDP session. Twice the frame interval is a safe upper bound
// for the longest a single capture can take.
function intervalSafeWait(fps) {
  return Math.min(200, Math.max(50, Math.round(2 * 1000 / fps)));
}

async function tabRecordStartImpl({
  sessionId, tabId,
  fps = 30,
  quality = 85,
  maxDurationMs = 600_000,
}) {
  if (!FFMPEG_PATH) detectFFmpeg();   // lazy re-scan: ffmpeg may have been installed since the bridge started
  if (!FFMPEG_PATH) {
    throw new Error('ffmpeg not found on Windows. Install it (winget install Gyan.FFmpeg), then just retry — the bridge re-detects it, no restart needed.');
  }

  const session = sessions.get(sessionId);
  if (!session) throw new Error(`Session "${sessionId}" not found`);
  const { tab } = resolveTabOrError(session, sessionId, { tabId });

  // One recording per tab.
  for (const r of tabRecordings.values()) {
    if (r.sessionId === sessionId && r.tabId === tab.tabId && !r.stopping) {
      throw new Error(`Tab ${tab.tabId} in session ${sessionId} is already being recorded (${r.recordingId})`);
    }
  }

  const recordingId = `rec-tab-${++tabRecCounter}`;
  const ts = Date.now();
  const filePath = path.join(
    RECORDINGS_DIR,
    `rec-tab-${sessionId}-${tab.tabId}-${ts}.webm`,
  );

  // Frames go to a temp dir during capture; ffmpeg muxes them to webm at
  // stop time via image2 demuxer with the f%06d.jpg pattern. This avoids
  // ffmpeg-stdin-streaming pitfalls (image2pipe and mjpeg both exit early
  // when input briefly stalls — observed in testing).
  const framesDir = path.join(RECORDINGS_DIR, `${path.basename(filePath, '.webm')}.frames`);
  fs.mkdirSync(framesDir, { recursive: true });

  const cdp = await tab.page.target().createCDPSession();

  const state = {
    recordingId, sessionId, tabId: tab.tabId,
    cdp, filePath, framesDir,
    fps, quality,
    startedAt: ts, stoppedAt: null,
    frameCount: 0, sizeBytes: 0,
    frameTimestamps: [], // wall-clock ms for each captured frame, used to mux with real timing
    stopping: false, stopTimer: null, stopReason: null,
    captureLoop: null,
  };
  tabRecordings.set(recordingId, state);

  // Bring tab to front so Chrome's compositor stays warm
  try { await tab.page.bringToFront(); } catch {}

  // ── CDP Page.startScreencast with frame ACK ─────────────────────────────
  // Chrome's compositor pushes JPEG frames as the page paints. Each frame
  // arrives with a sessionId that MUST be acknowledged via
  // Page.screencastFrameAck or Chrome stops sending after the first frame
  // (backpressure). Earlier impl (1.3.32) missed the ack — that's why we
  // saw "1 frame and stops" behavior, not paint-throttling. Tab-scoped at
  // the protocol level — the CDP session is bound to a specific Page target.
  //
  // everyNthFrame=1 means "every paint frame"; combined with the page's
  // own paint rate (60fps when foregrounded), this gives 60 fps capture
  // ceiling. Quality 60-85 keeps per-frame size manageable.
  state.frameHandler = async (event) => {
    if (state.stopping) return;
    const { data, sessionId: ackId } = event;
    const buf = Buffer.from(data, 'base64');
    const capturedAt = Date.now();
    state.frameCount += 1;
    state.sizeBytes += buf.length;
    state.frameTimestamps.push(capturedAt - state.startedAt);
    if (state.frameCount === 1) {
      console.log(`[${recordingId}] FIRST screencast frame (${buf.length} bytes)`);
    }
    const fname = `f${String(state.frameCount).padStart(6, '0')}.jpg`;
    try { fs.writeFileSync(path.join(state.framesDir, fname), buf); }
    catch (e) { console.error(`[${recordingId}] frame write failed: ${e.message}`); }
    // ACK so Chrome continues sending. Without this, only frame 1 arrives.
    try { await cdp.send('Page.screencastFrameAck', { sessionId: ackId }); }
    catch (e) {
      if (state.frameCount < 3) console.log(`[${recordingId}] ack error: ${e.message}`);
    }
  };
  cdp.on('Page.screencastFrame', state.frameHandler);

  await cdp.send('Page.startScreencast', {
    format: 'jpeg',
    quality,
    everyNthFrame: Math.max(1, Math.round(60 / fps)), // 1 -> 60fps, 2 -> 30fps, etc
  });

  console.log(`[${recordingId}] CDP screencast started @ target ${fps}fps (everyNthFrame=${Math.max(1, Math.round(60 / fps))}) → ${framesDir}`);

  state.stopTimer = setTimeout(() => {
    state.stopReason = 'maxDuration';
    tabRecordStopImpl({ sessionId, recordingId }).catch(() => {});
  }, maxDurationMs);

  return { recordingId, sessionId, tabId: tab.tabId, filePath, startedAt: ts };
}

async function tabRecordStopImpl({ sessionId, recordingId }) {
  const state = tabRecordings.get(recordingId);
  if (!state) throw new Error(`Recording ${recordingId} not found`);
  if (sessionId && state.sessionId !== sessionId) throw new Error(`Recording ${recordingId} belongs to a different session`);
  if (state.stopping) {
    const deadline = Date.now() + 5000;
    while (tabRecordings.has(recordingId) && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));
    throw new Error(`Recording ${recordingId} already stopping`);
  }
  state.stopping = true;
  if (state.stopTimer) clearTimeout(state.stopTimer);

  // Stop screencast — Chrome stops emitting Page.screencastFrame events
  try { await state.cdp.send('Page.stopScreencast'); } catch {}
  // Detach the CDP frame handler so any late frames are dropped
  if (state.frameHandler) {
    try { state.cdp.off('Page.screencastFrame', state.frameHandler); } catch {}
    state.frameHandler = null;
  }
  // Brief settle window for in-flight frames already in the JS event loop
  await new Promise(r => setTimeout(r, intervalSafeWait(state.fps)));
  try { await state.cdp.detach(); } catch {}

  state.stoppedAt = Date.now();
  const durationMs = state.stoppedAt - state.startedAt;

  // ── Mux the frames dir to webm via ffmpeg concat demuxer ──────────────
  // Each captured frame has a wall-clock timestamp (state.frameTimestamps).
  // We emit a concat list with explicit per-frame durations so playback
  // matches real wall time even when actual capture rate fell below the
  // requested fps (Page.captureScreenshot is GPU-bound; on a busy machine
  // 30 fps requested often delivers ~10-15 fps actual). Using the concat
  // demuxer with per-frame durations produces a video that plays in
  // real-time regardless of capture-rate variance.
  if (state.frameCount > 0) {
    const { spawn } = require('child_process');
    const totalDurMs = (state.frameTimestamps[state.frameTimestamps.length - 1] || 0) + 1; // +1 to avoid zero
    const actualFps = state.frameCount / (totalDurMs / 1000);

    // Build concat list: each frame followed by `duration <seconds>`. The
    // last file entry must be repeated (concat demuxer quirk) so the final
    // frame is held for its proper duration instead of clipped.
    const concatLines = [];
    for (let i = 0; i < state.frameCount; i++) {
      const fname = `f${String(i + 1).padStart(6, '0')}.jpg`;
      concatLines.push(`file '${fname}'`);
      const next = state.frameTimestamps[i + 1];
      const cur = state.frameTimestamps[i];
      const durSec = next != null
        ? Math.max(0.001, (next - cur) / 1000)
        : Math.max(0.001, 1 / Math.max(1, actualFps));
      concatLines.push(`duration ${durSec.toFixed(4)}`);
    }
    // Repeat the last file entry (no duration) — concat demuxer requirement
    concatLines.push(`file 'f${String(state.frameCount).padStart(6, '0')}.jpg'`);
    const concatPath = path.join(state.framesDir, 'concat.txt');
    fs.writeFileSync(concatPath, concatLines.join('\n'));

    const muxArgs = [
      '-y',
      '-loglevel', 'error',
      '-f', 'concat',
      '-safe', '0',
      '-i', concatPath,
      '-vsync', 'vfr',
      '-c:v', 'libvpx-vp9',
      '-b:v', '0',
      '-crf', '32',
      '-pix_fmt', 'yuv420p',
      '-row-mt', '1',
      '-deadline', 'realtime',
      '-cpu-used', '8',
      '-an',
      state.filePath,
    ];
    console.log(`[${state.recordingId}] muxing ${state.frameCount} frames over ${(totalDurMs/1000).toFixed(2)}s (avg ${actualFps.toFixed(1)} fps actual, ${state.fps} target)`);
    const muxStart = Date.now();
    await new Promise((resolve, reject) => {
      const proc = spawn(FFMPEG_PATH, muxArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
      let stderr = '';
      proc.stderr.on('data', b => { stderr += b.toString(); });
      proc.on('error', reject);
      proc.on('exit', code => {
        if (code === 0) return resolve();
        reject(new Error(`ffmpeg mux exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));
      });
    }).catch(e => {
      console.error(`[${state.recordingId}] mux failed: ${e.message}`);
    });
    console.log(`[${state.recordingId}] muxed ${state.frameCount} frames → ${state.filePath} in ${Date.now() - muxStart}ms`);

    // Clean up frames dir (keep on mux failure for debugging)
    if (fs.existsSync(state.filePath)) {
      try { fs.rmSync(state.framesDir, { recursive: true, force: true }); }
      catch (e) { console.log(`[${state.recordingId}] frames cleanup failed: ${e.message}`); }
    }
  } else {
    console.log(`[${state.recordingId}] zero frames captured — no webm produced`);
  }

  // Sidecar metadata (used by browser_record_list)
  try {
    const sidecar = {
      recordingId, sessionId: state.sessionId, tabId: state.tabId,
      filePath: state.filePath, fps: state.fps, quality: state.quality,
      startedAt: state.startedAt, stoppedAt: state.stoppedAt,
      durationMs, frameCount: state.frameCount, sizeBytes: state.sizeBytes,
      stopReason: state.stopReason || 'requested',
    };
    fs.writeFileSync(state.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));
  } catch (e) {
    console.error(`[${state.recordingId}] sidecar write failed: ${e.message}`);
  }

  let sizeKB = 0;
  try { sizeKB = Math.round(fs.statSync(state.filePath).size / 1024); } catch {}

  const actualFps = durationMs > 0 ? +(state.frameCount / (durationMs / 1000)).toFixed(2) : 0;

  tabRecordings.delete(recordingId);
  return {
    recordingId,
    sessionId: state.sessionId,
    tabId: state.tabId,
    filePath: state.filePath,
    sizeKB,
    durationMs,
    frameCount: state.frameCount,
    fpsTarget: state.fps,
    fpsActual: actualFps,
    stopReason: state.stopReason || 'requested',
  };
}

function tabRecordStatusImpl({ sessionId } = {}) {
  const active = [];
  for (const s of tabRecordings.values()) {
    if (sessionId && s.sessionId !== sessionId) continue;
    if (s.stopping) continue;
    const elapsedMs = Date.now() - s.startedAt;
    const actualFps = elapsedMs > 100 ? +(s.frameCount / (elapsedMs / 1000)).toFixed(2) : null;
    active.push({
      recordingId: s.recordingId, sessionId: s.sessionId, tabId: s.tabId,
      filePath: s.filePath,
      fpsTarget: s.fps,
      fpsActual: actualFps,
      frameCount: s.frameCount,
      durationMs: elapsedMs,
      sizeBytesApprox: s.sizeBytes,
      startedAt: s.startedAt,
    });
  }
  return active;
}

function tabRecordListImpl() {
  // Scan RECORDINGS_DIR for completed tab recordings (.webm files matching
  // the tab pattern). Each has an optional sidecar .json with metadata.
  let entries = [];
  try {
    entries = fs.readdirSync(RECORDINGS_DIR)
      .filter(f => f.startsWith('rec-tab-') && f.endsWith('.webm'))
      .map(f => {
        const fp = path.join(RECORDINGS_DIR, f);
        const stat = fs.statSync(fp);
        const sidecarPath = fp.replace(/\.webm$/, '.json');
        let sidecar = {};
        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}
        return {
          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),
          filePath: fp,
          sizeKB: Math.round(stat.size / 1024),
          durationMs: sidecar.durationMs || null,
          fps: sidecar.fps || null,
          sessionId: sidecar.sessionId || null,
          tabId: sidecar.tabId || null,
          mtime: stat.mtime.toISOString(),
        };
      });
  } catch {}
  return entries;
}

function desktopRecordListImpl() {
  let entries = [];
  try {
    entries = fs.readdirSync(RECORDINGS_DIR)
      .filter(f => f.startsWith('rec-desktop-') && f.endsWith('.webm'))
      .map(f => {
        const fp = path.join(RECORDINGS_DIR, f);
        const stat = fs.statSync(fp);
        const sidecarPath = fp.replace(/\.webm$/, '.json');
        let sidecar = {};
        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}
        return {
          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),
          filePath: fp,
          sizeKB: Math.round(stat.size / 1024),
          durationMs: sidecar.durationMs || null,
          reason: sidecar.reason || null,
          mtime: stat.mtime.toISOString(),
        };
      });
  } catch {}
  return entries;
}

// ════════════════════════════════════════════════════════════════════════════

// Graceful shutdown: disconnect from Chrome instead of killing it
// Chrome stays alive for reconnect on next bridge startup
async function gracefulShutdown(signal) {
  console.log(`\n[${signal}] Draining recordings + disconnecting from Chrome (Chrome stays alive for reconnect)...`);

  // Drain active desktop recording
  if (desktopRecorder.current) {
    try {
      desktopRecorder.current.stopReason = 'bridgeShutdown';
      await desktopRecordStopImpl({ recordingId: desktopRecorder.current.recordingId });
    } catch (e) {
      console.error(`  desktop recording stop failed: ${e.message}`);
    }
  }
  // Close recorder window so its WebM has a valid trailing cluster
  if (desktopRecorder.browser) {
    try { await desktopRecorder.browser.close(); } catch {}
  }

  // Drain active tab recordings
  for (const [recId, _state] of [...tabRecordings.entries()]) {
    try {
      _state.stopReason = 'bridgeShutdown';
      await tabRecordStopImpl({ sessionId: _state.sessionId, recordingId: recId });
    } catch (e) {
      console.error(`  tab recording ${recId} stop failed: ${e.message}`);
    }
  }

  for (const [name, entry] of browsers) {
    try {
      entry.browser.disconnect();
      console.log(`  Disconnected from browser "${name}"`);
    } catch (e) {
      console.error(`  Error disconnecting "${name}": ${e.message}`);
    }
  }
  browsers.clear();
  sessions.clear();
  process.exit(0);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));