app
Adom Desktop - Puppeteer Bridge
Public Made by Adomby adom
The Adom Desktop Puppeteer (pup) bridge — drive a real headful browser on the user's desktop from the cloud: open/screenshot/eval/record. Cold-start drives the browser ALREADY on the machine (installed Chrome, else Microsoft Edge — every Windows PC ships with Edge) with a fresh isolated profile, so the common case needs NO download; Chrome for Testing is fetched only as a last resort. Two artifacts: the Releases zip is the bridge runtime (also bundled in Adom Desktop); the pkg installs the container-side Claude skills.
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'));