thinkpool-pair 0.7.447 → 0.7.449
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/account.mjs +1 -1
- package/browser-job.mjs +1 -0
- package/package.json +2 -1
- package/publish-manifest.json +10 -9
- package/thinkpool-capabilities.json +37 -10
- package/viewport.mjs +1 -1
package/README.md
CHANGED
|
@@ -227,6 +227,31 @@ their own sandbox cannot bind a port or launch a browser:
|
|
|
227
227
|
Preview roots are read-only, cannot escape the lane worktree, and accept only
|
|
228
228
|
the preview server’s loopback origin for page requests.
|
|
229
229
|
|
|
230
|
+
The browser starts on demand and is reused while work is active. After the
|
|
231
|
+
last active or queued job finishes, 60 seconds of inactivity closes it; the
|
|
232
|
+
next job starts it again. Each job uses a fresh browser context, disposed on
|
|
233
|
+
success or failure. Desktop (1440×900) and mobile (390×844) run sequentially.
|
|
234
|
+
Mobile enables touch, mobile viewport behavior, and a mobile Chromium identity
|
|
235
|
+
at device scale 1. It does not claim to emulate Safari or a physical iPhone.
|
|
236
|
+
|
|
237
|
+
Managed preview jobs across all rooms share one host slot with the repository
|
|
238
|
+
browser checks and the Design render/freeze helpers, including the browser
|
|
239
|
+
tests in `test:unit`. The unit runner also uses one worker. The slot uses an exclusive loopback listener on port 47683;
|
|
240
|
+
it serves no application protocol and disappears if its owning process exits.
|
|
241
|
+
Waiting jobs time out after two minutes without stealing an active slot.
|
|
242
|
+
Personal Chrome tabs and independently launched browser scripts are outside
|
|
243
|
+
this managed limit.
|
|
244
|
+
|
|
245
|
+
From the repository, run `npm run test:browser` for sequential Chromium and
|
|
246
|
+
WebKit viewport, touch/click, and session-isolation checks. Use
|
|
247
|
+
`npm run test:browser -- --engine=chromium` on older Macs without supported
|
|
248
|
+
WebKit. `TP_BROWSER_PATH` also selects an installed Chrome for the app tests
|
|
249
|
+
and Design helpers when bundled Chromium cannot run on the host. CI installs
|
|
250
|
+
both pinned Playwright engines and runs the existing landing focus and Lynx
|
|
251
|
+
request-lifecycle app tests under WebKit (`TP_TEST_BROWSER_ENGINE=webkit`).
|
|
252
|
+
Safari-sensitive releases still require a real-device check of login, keyboard/focus, scrolling, and the
|
|
253
|
+
changed interaction; record the device/OS and result separately.
|
|
254
|
+
|
|
230
255
|
## Optional bridge environment variables
|
|
231
256
|
|
|
232
257
|
| Variable | Purpose |
|
package/account.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{spawn as e}from"node:child_process";import{fileURLToPath as r}from"node:url";import{randomUUID as t}from"node:crypto";import o from"node:path";import n from"node:fs";import{createClient as s}from"@supabase/supabase-js";import i from"node:os";import{saveAuth as a,loadAuth as c,markAuthReconnectRequired as d,loadDirs as l,bindDir as p,loadServed as u,rememberServed as m,loadDefaultDir as f,saveDefaultDir as h,claudeRecentProjectDir as y}from"./auth-store.mjs";import{isSafeToRestart as g}from"./update-gate.mjs";import{makeThrottledTrack as v,presenceRecoveryEligible as w,presenceSelfEchoObservation as b,recoveryBackoffMs as k,reducePresenceChannelEvidence as _,subscribeWithoutStartupDeadlock as $}from"./presence.mjs";import{publicKeyB64 as R,announceProviders as S,listProviders as T,addProvider as I,addProviderModel as O,removeProvider as x,unseal as C}from"./providers.mjs";import{supervisorServes as A,supervisorRoomsToStop as P}from"./serve-consent.mjs";import{resolveServeDir as q}from"./serve-dir.mjs";import{pairKeyFor as N,pairTopic as E,CROSSROOM_BUS as M}from"./cross-terminal.mjs";import{installedAgentCommands as U}from"./agent-detect.mjs";import{agentVisibilityStates as L,setAgentPickerVisibility as D}from"./agent-visibility.mjs";import{hostMemoryAdmission as j}from"./host-memory.mjs";import{HostCapacityAuthority as F}from"./host-capacity.mjs";import{createPairBusBroker as H,mergePairRoomRoster as B,pairBusStatusFromRealtime as z,PAIR_BUS_STATUS as G}from"./pair-bus.mjs";import{readCloudSessionTranscript as K}from"./cloud-session-transcript.mjs";import{clearSupervisorReady as W,supervisorPresenceEchoed as J,writeSupervisorReady as V}from"./supervisor-ready.mjs";import{PAIR_CLI as X,pairCli as Y}from"./command-guidance.mjs";import{syncRealtimeAuth as Z}from"./design-edit.mjs";import{crossRotationLock as Q}from"./rotation-lock.mjs";import{loadOrCreateBridgeId as ee,bridgeLabel as re}from"./bridge-identity.mjs";const te=(()=>{try{return JSON.parse(n.readFileSync(new URL("./package.json",import.meta.url),"utf8")).version}catch{return null}})(),oe=r(new URL("./bridge.mjs",import.meta.url));export function createStartupAnchor({set:e=setInterval,clear:r=clearInterval}={}){const t=e(()=>{},6e4);let o=!1;return()=>{o||(o=!0,r(t))}}const ne=o.join(i.homedir(),".thinkpool-pair","account.lock");function se(e){if(!e||e===process.pid)return!1;try{return process.kill(e,0),!0}catch(e){return"EPERM"===e.code}}export function acquireSingleton(e=ne){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{return!1}for(let r=0;r<3;r++)try{const r=n.openSync(e,"wx");return n.writeSync(r,String(process.pid)),n.closeSync(r),!0}catch(r){if("EEXIST"!==r.code)return!1;let t=0;try{t=parseInt(n.readFileSync(e,"utf8").trim(),10)||0}catch{return!1}if(se(t))return!1;try{n.rmSync(e,{force:!0})}catch{return!1}}return!1}export function releaseSingleton(e=ne){try{(parseInt(n.readFileSync(e,"utf8").trim(),10)||0)===process.pid&&n.rmSync(e,{force:!0})}catch{}}export function acquireMachineLock(e=ne){return acquireSingleton(e)}export function releaseMachineLock(e=ne){return releaseSingleton(e)}export function shouldHoldMachineLock(e=process.env){return"1"!==e.THINKPOOL_PAIR_ACCOUNT_CHILD}export async function runLogin(e,r,t){const o=s(e,r,{auth:{persistSession:!1}}),{data:n,error:i}=await o.rpc("start_device_code"),c=Array.isArray(n)?n[0]:n;!i&&c?.device_code||(process.stderr.write(`\n ✗ couldn't start login: ${i?.message||"no response"}\n`),process.exit(1));const{device_code:d,user_code:l}=c,p=`${t}/code/link?c=${encodeURIComponent(l)}`;process.stderr.write(`\n ◆ Link this device to your thinkpool account.\n Open (signed in) and approve:\n\n ${p}\n\n code: ${l}\n\n Waiting…\n`);const u=Date.now(),m=async()=>{Date.now()-u>3e5&&(process.stderr.write(`\n ◇ link timed out — run \`${Y("login")}\` again.\n`),process.exit(1));try{const{data:e}=await o.rpc("claim_device_code",{p_device_code:d});"approved"===e?.status&&e.session?.refresh_token&&(a(e.session),process.stderr.write(`\n ◆ linked as ${e.session.email||"your account"}. You can close the browser tab.\n Now run: ${X}\n`),process.exit(0)),"expired"!==e?.status&&"not_found"!==e?.status||(process.stderr.write(`\n ◇ link expired — run \`${Y("login")}\` again.\n`),process.exit(1))}catch{}setTimeout(m,3e3)};m(),await new Promise(()=>{})}const ie=e=>new Promise(r=>{const t=setTimeout(r,e);t.unref?.()});export function withTimeout(e,r,t="op"){return new Promise((o,n)=>{const s=setTimeout(()=>n(new Error(`${t} timed out after ${r}ms`)),r);s.unref?.(),Promise.resolve(e).then(e=>{clearTimeout(s),o(e)},e=>{clearTimeout(s),n(e)})})}export function savedTokenStillValid(e,r=Math.floor(Date.now()/1e3),t=300){if(!e?.access_token)return!1;const o=e.expires_at||function(e){try{const r=String(e).split(".")[1],t=Buffer.from(r.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"),o=JSON.parse(t)?.exp;return"number"==typeof o?o:0}catch{return 0}}(e.access_token);return!!o&&o-r>t}const ae=o.join(i.homedir(),".thinkpool-pair","refresh.lock");export function acquireRefreshLock(e=ae,r=3e4){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{}for(let t=0;t<2;t++)try{const r=n.openSync(e,"wx");return n.writeSync(r,JSON.stringify({pid:process.pid,at:Date.now()})),n.closeSync(r),!0}catch(t){if("EEXIST"!==t.code)return!0;let o=1/0;try{o=Date.now()-(JSON.parse(n.readFileSync(e,"utf8"))?.at||0)}catch{o=1/0}if(o<r)return!1;try{n.rmSync(e,{force:!0})}catch{return!0}}return!1}export function releaseRefreshLock(e=ae){try{n.rmSync(e,{force:!0})}catch{}}async function ce(e,r){if(!r?.access_token||!r?.refresh_token)return null;try{const{data:t,error:o}=await e.auth.setSession({access_token:r.access_token,refresh_token:r.refresh_token});return o||!t?.session?null:t.session}catch{return null}}export async function refreshAccountSession(e,r,t={}){const{load:o=c,save:n=a,acquire:s=acquireRefreshLock,release:i=releaseRefreshLock,wait:d=ie,crossLock:l=null}=t,p=async r=>{const t=o();if(t?.refresh_token&&r&&t.refresh_token!==r){const r=await ce(e,t);if(r)return{session:r,adopted:!0}}return null};let u=await p(r);if(u)return u;const m=o()?.refresh_token||r;if(!m)return null;let f=!1;const h=async()=>{if(l&&!f){let e=!0;try{e=await l.acquire()}catch{e=!0}if(!1===e)return!1;f=!0}return s()};let y=await h();if(!y){for(let e=0;e<20;e++){if(await d(250),u=await p(m),u)return f&&await(l?.release?.()),f=!1,u;if(await h()){y=!0;break}}if(!y)return f&&await(l?.release?.()),f=!1,null}try{if(u=await p(m),u)return u;const r=o()?.refresh_token||m,{data:t,error:s}=await e.auth.refreshSession({refresh_token:r});return s||!t?.session?{session:null,error:s||null}:(n(t.session),{session:t.session,adopted:!1})}finally{if(f){f=!1;try{await(l?.release?.())}catch{}}y&&i()}}export const ACCOUNT_RECONNECT_ERROR_CODES=new Set(["refresh_token_not_found","refresh_token_already_used","session_not_found"]);export function accountRefreshNeedsReconnect(e){return ACCOUNT_RECONNECT_ERROR_CODES.has(String(e?.code||""))}export async function authedClient(e,r,{crossLockFor:t}={}){const o=c();if(!o?.refresh_token)return null;if(o.reconnect_required)return{reconnectRequired:!0};const n=s(e,r,{auth:{persistSession:!1,autoRefreshToken:!1}});if(savedTokenStillValid(o)){const e=await ce(n,o);if(e)return{sb:n,session:e}}const i=await refreshAccountSession(n,o.refresh_token,t?{crossLock:t(n)}:{});return i?.session?{sb:n,session:i.session}:accountRefreshNeedsReconnect(i?.error)?(d(i.error.code),{reconnectRequired:!0}):null}export async function authedClientWithRetry(e,r,t={}){const{attempt:o=authedClient,load:n=c,wait:s=ie,onRetry:i=null,maxAttempts:a=1/0,baseMs:d=15e3,capMs:l=3e5,crossLockFor:p=null}=t;let u=d;for(let t=1;t<=a;t++){const c=await o(e,r,{crossLockFor:p});if(c?.reconnectRequired)return c;if(c)return c;if(!n()?.refresh_token)return null;if(t>=a)break;i?.(u,t),await s(u),u=Math.min(2*u,l)}return null}export function claimLoopWedged({lastClaimOkAt:e,now:r,quietMsThreshold:t=4e4}){return r-e>=t}export function scheduleTokenRefresh({sb:e,session:r,onRefreshed:t,onReconnectRequired:o,skewMs:n=9e4,forceMs:s=0,retryMs:i=3e4,refresh:a=refreshAccountSession,crossLock:c=null}){let d=null,l=r,p=!1,u=!1;const m=e=>{p||(clearTimeout(d),d=setTimeout(h,Math.max(1e3,e)),d?.unref&&d.unref())},f=()=>{if(s)return s;const e=l?.expires_at?1e3*l.expires_at:Date.now()+36e5;return Math.max(3e4,e-Date.now()-n)};async function h(){if(!p)try{const r=await a(e,l.refresh_token,c?{crossLock:c}:void 0);if(!r?.session){if(accountRefreshNeedsReconnect(r?.error)&&!u){u=!0;try{await(o?.(r.error))}catch{}}throw r?.error||new Error("no session")}l=r.session;try{e.realtime?.setAuth?.(l.access_token)}catch{}u=!1;try{await(t?.(l))}catch{}m(f())}catch{m(i)}}return m(f()),{cancel:()=>{p=!0,clearTimeout(d)},_tick:h}}export function runBind(e,r){const t=(e||"").toUpperCase().trim();t||(console.error(`usage: ${Y("bind","<ROOM>","<directory>")}`),process.exit(1));const s=o.resolve(r||".");n.existsSync(s)&&n.statSync(s).isDirectory()||(console.error(`✗ not a directory: ${s}`),process.exit(1)),p(t,s),console.error(`✓ bound ${t} → ${s}\n Run \`${X}\` (or restart it) to serve this session.`),process.exit(0)}export async function runAccount(r,s){acquireSingleton()||(console.error(`\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n \`${Y("<ROOM>")}\`.\n`),process.exit(0)),W();const c=createStartupAnchor(),X=ee(),ne=t(),se=e=>Q(e,X),ie=await authedClientWithRetry(r,s,{crossLockFor:se,onRetry:e=>process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(e/1e3)}s. Saved login left in place.\n`)});ie||(releaseSingleton(),console.error(`\n ◇ Not linked to a thinkpool account on this machine.\n Link it: ${Y("login")}\n`),process.exit(0)),ie.reconnectRequired&&(c(),releaseSingleton(),console.error("\n ◇ This bridge lost its thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n"),process.exit(0));const{sb:ae,session:ce}=ie,de=ce.user?.email||"your account";let le=ce.access_token||null;const pe=process.cwd();let ue=!1;try{const e=await import("./service.mjs");ue=e.serviceActive(null)}catch{}const me=()=>"1"===process.env.THINKPOOL_PAIR_AUTOUPDATE||ue,fe="1"===process.env.THINKPOOL_PAIR_AUTOUPDATE?" (auto-updating background service — survives reboot, tracks @latest)\n":ue?" (background service — survives reboot, restarts on crash, pinned to this version)\n":` (run hands-off + auto-updating: ${Y("install-service")})\n`,he=f()||y()||pe;process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${de}\n New sessions auto-serve from ${he}${f()?" (your default)":y()?" (your most-recent Claude Code project)":""}\n (change it: ${Y("set-default-dir","<dir>")} · one room: ${Y("bind","<code>","<dir>")})\n${fe}`);const ye=new Map,ge=new F,ve=new Map,we=new Map,be=new Map,ke=new Set,_e=new Map,$e=new Map,Re=new Map,Se=new Map,Te=new Map;let Ie=!1,Oe=null,xe=!1;const Ce=new Map,Ae=new Set,Pe=new Map,qe=i.hostname().replace(/\.local$/,""),Ne=(t?.()||"").slice(0,8);let Ee=null;console.log(`◇ supervisor boot sup=${Ne} host=${qe} pid=${process.pid} npx=${te||"dev"}`),await Z(ae,le,s)||process.stderr.write("\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n");const Me=ae.channel(`tpacct:${ce.user.id}`,{config:{presence:{key:qe},broadcast:{self:!1}}}),Ue=ae.channel(`tpacct-control:${ce.user.id}`,{config:{private:!0,broadcast:{self:!1}}});let Le=!1,De=()=>{};Ue.on("broadcast",{event:"bind-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();let t=String(e?.dir||"").trim();t.startsWith("~")&&(t=o.join(i.homedir(),t.slice(1))),r&&t&&(n.existsSync(t)?(p(r,t),e?.setDefault&&(h(t),process.stderr.write(`\n ◆ default project dir set → ${t}\n`)),process.stderr.write(`\n ◆ ${r} bound → ${t} (from the web). Serving…\n`),De()):process.stderr.write(`\n ◇ bind ${r}: directory not found (${t}) — ignored.\n`))}),Ue.on("broadcast",{event:"serve-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();/^[A-Z0-9]{4,12}$/.test(r)&&De()}),Ue.on("broadcast",{event:"agent-output-hygiene"},({payload:e})=>{if("boolean"==typeof e?.enabled)for(const r of ye.values())try{r.send({t:"agent-output-hygiene",enabled:e.enabled})}catch{}}),Ue.on("broadcast",{event:"shutdown"},async()=>{if(process.stderr.write("\n ◇ disconnect requested from the dashboard — stopping bridge.\n"),setTimeout(()=>{try{releaseSingleton()}catch{}process.exit(0)},2500),me())try{(await import("./service.mjs")).uninstallService(null)}catch{}process.kill(process.pid,"SIGTERM")});let je=!1;Ue.on("broadcast",{event:"restart"},async({payload:e}={})=>{const r=e?.nonce,t=async(e,t)=>{try{await Ue.send({type:"broadcast",event:"restart-status",payload:{nonce:r,ok:e,error:t}})}catch{}};if(je||xe)await t(!1,"a bridge update is already being prepared");else{je=!0;try{const r=await import("./service.mjs"),o=r.serviceActive(null),n=o?r.stageServiceUpdate(null):null;if(o&&!n)return void await t(!1,"the published runtime could not be staged; the existing bridge was left running");Oe=n||("string"==typeof e?.version?e.version:te)||"restart",Ie=!0,await t(!0)}catch(e){return void await t(!1,`restart preparation failed: ${e?.message||e}`)}finally{je=!1}Ir()}});let Fe="init",He="connected";const Be=v(Me,{minMs:5e3,onStatus:e=>{Fe=e}}),ze=R(),Ge=()=>Be({name:qe,bridgeId:ne,version:te,rooms:[...ye.keys()],readyRooms:[...ke],refused:[...Pe].map(([e,r])=>({code:e,reason:r})),service:me(),providerPubKey:ze,providers:S(),agents:L(U()),accountAuthState:He,ts:Date.now()}),Ke=async e=>{try{await withTimeout(ae.rpc("report_bridge_auth_state",{p_bridge_id:X,p_state:e}),5e3,"report_bridge_auth_state")}catch{}},We=(e,r)=>{try{Ue.send({type:"broadcast",event:e,payload:r})}catch{}};Ue.on("broadcast",{event:"master-brief-cleanup"},({payload:e})=>{const r=String(e?.nonce||""),t=String(e?.transcript||"").trim();if(!r||!t||t.length>12e3)return void We("master-brief-cleanup-res",{nonce:r,ok:!1,error:"That recording could not be cleaned up."});const o=[...ke].find(e=>!1!==ye.get(e)?.connected),n=o?ye.get(o):null;if(n)try{n.send({t:"master-brief-cleanup",nonce:r,transcript:t,speaker:e?.speaker})}catch{We("master-brief-cleanup-res",{nonce:r,ok:!1,error:"The connected agent was unavailable."})}else We("master-brief-cleanup-res",{nonce:r,ok:!1,error:"Open a session with a connected agent to clean this Brief."})}),Ue.on("broadcast",{event:"provider-add"},async({payload:e})=>{const r=e?.nonce;let t;try{t=C(e?.sealed)}catch{return We("provider-add-res",{nonce:r,ok:!1,error:"could not decrypt the sealed payload"})}const o=I(t);o.ok&&(Ge(),process.stderr.write(`\n ◆ provider added (${String(t?.name||"").slice(0,40)}) — re-announced.\n`)),We("provider-add-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,id:o.ok?o.id:void 0})}),Ue.on("broadcast",{event:"provider-add-model"},async({payload:e})=>{const r=e?.nonce,t=O({id:e?.id,model:e?.model,name:e?.name});t.ok&&(Ge(),process.stderr.write(`\n ◆ model added to an existing provider (${String(e?.model||"").slice(0,40)}) — re-announced.\n`)),We("provider-add-model-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error,id:t.ok?t.id:void 0})}),Ue.on("broadcast",{event:"provider-remove"},async({payload:e})=>{const r=e?.nonce,t=x(e?.id);t.ok&&(Ge(),process.stderr.write("\n ◆ provider removed — re-announced.\n")),We("provider-remove-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error})}),Ue.on("broadcast",{event:"providers-list-req"},async({payload:e})=>{const r=e?.nonce;We("providers-list-res",{nonce:r,ok:!0,providers:T()})}),Ue.on("broadcast",{event:"agent-visibility-set"},async({payload:e})=>{const r=e?.nonce,t=U(),o=D(e?.cmd,!0===e?.enabled,{installed:t});if(o.ok){Ge();for(const e of ye.values())try{e.send({t:"agent-visibility-changed"})}catch{}process.stderr.write(`\n ◆ terminal picker agents updated — ${o.agents.filter(e=>e.enabled).map(e=>e.cmd).join(", ")} visible.\n`)}We("agent-visibility-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,agents:o.ok?o.agents:void 0})});let Je=_(),Ve=!1,Xe=!1;const Ye=()=>{if(Xe||!Ve)return;let e=!1;try{e=J(Me.presenceState?.(),qe,ne,te)}catch{e=!1}e&&(Xe=V({version:te,bridgeId:ne}),Xe||process.stderr.write("\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n"))};Me.on("presence",{event:"sync"},()=>{Je=_(Je,"sync"),Ye()});const Ze=await $(Me,e=>{if("SUBSCRIBED"===e)Je=_(Je,e),Ge();else if("CLOSED"===e||"CHANNEL_ERROR"===e||"TIMED_OUT"===e){Je=_(Je,e);try{ae.realtime?.connect?.()}catch{}}});Ze.connected||process.stderr.write(`\n ◇ account realtime not ready at startup (${Ze.status}) — staying alive and recovering in process.\n`);const Qe=await $(Ue,e=>{"CLOSED"!==e&&"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ⚠ private account control ${e}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)});Qe.connected||process.stderr.write(`\n ◇ private account control not ready at startup (${Qe.status}) — presence remains available.\n`);const er=scheduleTokenRefresh({sb:ae,session:ce,crossLock:se(ae),onRefreshed:async e=>{He="connected",a(e),Ge(),await Ke("connected"),le=e.access_token||le;for(const e of ye.values())try{e.send({t:"token",accessToken:le})}catch{}},onReconnectRequired:async e=>{He="reconnect-required",d(e?.code||"refresh-token-invalid"),Ge(),await Ke("reconnect-required"),process.stderr.write("\n ◇ This bridge lost its thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n")},forceMs:parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS,10)||0});let rr=!1,tr=0,or=null,nr=Date.now(),sr=0,ir=0,ar="";const cr=async()=>{try{const{data:e}=await withTimeout(ae.rpc("claim_bridge",{p_bridge_id:X,p_hostname:qe}),1e4,"claim_bridge");nr=Date.now();const r=Array.isArray(e)?e[0]:e,t=!!r?.held,o=r?.holder_host||"?";return t&&!rr?process.stderr.write("\n ◆ holding this account — serving your sessions (single active bridge).\n"):!t&&rr?process.stderr.write(`\n ◇ another device (${o}) took over this account — standing by.\n`):t||or===o||process.stderr.write(`\n ◇ another device (${o}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`),rr=t,tr=t&&Number.isSafeInteger(Number(r?.fencing))&&Number(r.fencing)>0?Number(r.fencing):0,or=t?null:o,Ee!==t&&(console.log(`claim_tick sup=${Ne} held=${t} holder=${t?qe:o}`),Ee=t),t}catch{return rr}},dr=H({channels:Se,randomId:t}),lr=e=>dr.resolve(e),pr=(e,r,t)=>dr.requestFirst(e,r,t),ur=(e,r,t)=>dr.requestCollect(e,r,t),mr=async e=>{try{const r=await withTimeout(ae.rpc("list_pair_rooms",{p_code:e}),2500,"paired-room roster");return r?.error||!Array.isArray(r?.data)?{verified:!1,rooms:[]}:{verified:!0,rooms:r.data}}catch{return{verified:!1,rooms:[]}}},fr=(e,r)=>{if(!e||Se.has(e))return;const o=E(r);if(!o)return;const n=ae.channel(o,{config:{private:!0,broadcast:{self:!1}}}),s={channel:n,key:r,status:G.JOINING};n.on("broadcast",{event:"peer-list-req"},({payload:r})=>((e,r,t)=>{if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return;const o=[...ye.keys()].filter(r=>Re.get(r)===e).map(e=>({code:e,name:_e.get(e)||null,host:qe}));try{r.send({type:"broadcast",event:"peer-list-res",payload:{nonce:t?.nonce,rooms:o}})}catch{}})(e,n,r)).on("broadcast",{event:"peer-list-res"},({payload:e})=>lr(e)).on("broadcast",{event:"peer-peek-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-peek-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return n({error:"Cross-session reach is off on this device."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=ye.get(s);if(!i||Re.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();$e.set(a,{reply:e=>n({text:e.text,error:e.error}),askerRoom:null});try{i.send({t:"pair-peek-local",reqId:a,terminal:o.terminal,lines:o.lines})}catch{$e.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-peek-res"},({payload:e})=>lr(e)).on("broadcast",{event:"peer-post-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-post-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return n({error:"Cross-room posting is off on this device."});if(!(e=>{const r=Date.now();let t=Te.get(e);return(!t||r>=t.resetAt)&&(t={count:0,resetAt:r+6e4},Te.set(e,t)),!(t.count>=6||(t.count++,0))})(e))return n({error:"Too many cross-room posts to this device right now — try again shortly."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=ye.get(s);if(!i||Re.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();$e.set(a,{reply:e=>n({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:null});try{i.send({t:"pair-post-local",reqId:a,fromRoom:o.fromRoom,fromHost:o.fromHost,fromTerminalName:o.fromTerminalName,text:o.text,terminal:o.terminal})}catch{$e.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-post-res"},({payload:e})=>lr(e)).subscribe(e=>{s.status=z(e),"SUBSCRIBED"===e?process.stderr.write("\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n"):"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ◇ pair bus ${e.toLowerCase().replace("_"," ")} — partner sessions are offline until it reconnects.\n`)}),Se.set(e,s)};let hr=0;const yr=async()=>{const e=Date.now();if(!(hr&&e-hr<6e4)){hr=e;try{await gr()}finally{hr===e&&(hr=0)}}};De=()=>{yr().catch(()=>{})};const gr=async()=>{if(await(async()=>{try{const{data:e}=await withTimeout(ae.rpc("enroll_bridge",{p_bridge_id:X,p_label:re(),p_version:te}),1e4,"enroll_bridge"),r=Array.isArray(e)?e[0]:e,t=r?.reason||"";t!==ar&&("revoked"===t?process.stderr.write("\n ◇ this device was revoked from your thinkpool account — standing by.\n Restore it from the account page to serve again.\n"):"foreign"===t&&process.stderr.write("\n ◇ this machine's bridge identity belongs to a different thinkpool account.\n Remove ~/.thinkpool-pair/bridge-id and relink to enroll fresh.\n"),ar=t)}catch{}})(),!await cr()){if(ye.size)for(const[,e]of ye)try{e.kill("SIGTERM")}catch{}return void Ge()}let r=[],s=!1;const a=ce.user.id;try{const{data:e,error:t}=await ae.from("code_sessions").select("code,name,last_active_at,owner_id,participants,closed_at").eq("owner_id",a).is("closed_at",null).order("last_active_at",{ascending:!1}).limit(20);if(t)throw t;r=e||[],s=!0;const o=new Set(r.map(e=>e.code));for(const e of[...Pe.keys()])o.has(e)||Pe.delete(e)}catch{}if(s)for(const e of P({rooms:r,childRooms:ye.keys(),myUid:a})){const r=ye.get(e);if(r){console.log(`child_retire sup=${Ne} room=${e} pid=${r.pid||"?"} reason=not-active-owned`);try{r.kill("SIGTERM")}catch{}}}const c=l(),d=u();for(const s of r){const r=s.code;if(!r||ye.has(r)){r&&Pe.delete(r);continue}if(!A({ownerId:s.owner_id,myUid:a,closedAt:s.closed_at}))continue;const l=q({bound:c[r],remembered:d[r],defaultDir:f()||y()||pe,home:i.homedir()});if(l.refuse){if(Pe.set(r,l.reason),!Ae.has(r)){Ae.add(r);const e="home"===l.reason?`no project directory set — refusing to serve it from your home folder (${l.dir}); the agent would lose its session context and the room would show "${o.basename(l.dir)}" as the repo.`:"no directory to serve it from.";process.stderr.write(`\n ◇ ${r}: ${e}\n Bind it to its repo: ${Y("bind",r,"<repo-dir>")}\n`)}continue}const p=l.dir;if(!n.existsSync(p)){Pe.set(r,"dir-gone"),Ae.has(r)||(Ae.add(r),process.stderr.write(`\n ◇ ${r}: bound directory is gone (${p}) — re-bind it.\n`));continue}Ae.delete(r),Pe.delete(r),l.fromDefault&&m(r,p),_e.set(r,s.name||null),process.stderr.write(`\n ◆ serving ${r}${s.name?` "${s.name}"`:""} → ${p}${c[r]?"":" (default)"}\n`);const u={...process.env,THINKPOOL_PAIR_ACCOUNT_CHILD:"1",TP_ACCOUNT_BRIDGE_ID:X,TP_ACCOUNT_BRIDGE_FENCING:String(tr)};delete u.THINKPOOL_PAIR_AUTOUPDATE,le&&(u.TP_ACCESS_TOKEN=le);const h=ye.has(r),g=j(`spawn the room bridge for ${r}`);if(!g.ok){Ae.has(`memory:${r}`)||(Ae.add(`memory:${r}`),process.stderr.write(`\n ◇ ${r}: ${g.reason}\n`)),Pe.set(r,"host-memory");continue}Ae.delete(`memory:${r}`);const v=e(process.execPath,[oe,r,"--headless","--auto=claude"],{cwd:p,stdio:["inherit","inherit","inherit","ipc"],env:u});console.log(`child_spawn sup=${Ne} room=${r} pid=${v.pid} dir=${p} had_prior=${h}`),ye.set(r,v),ke.delete(r),ve.set(r,!1),we.set(r,!1),be.set(r,!1),v.on("message",e=>{if(e)if("room-ready"===e.t)ke.add(r),console.log(`child_ready sup=${Ne} room=${r} pid=${v.pid}`),Ge();else if("idle"===e.t)ve.set(r,!!e.idle),we.set(r,null!=e.between?!!e.between:!!e.idle),be.set(r,!!e.webPeer),Ir();else if("busy"===e.t)ve.set(r,!1),we.set(r,!1);else if("master-brief-cleanup-res"===e.t)We("master-brief-cleanup-res",{nonce:e.nonce,ok:!0===e.ok,...e.brief?{brief:e.brief}:{},...e.error?{error:e.error}:{}});else if("capacity-acquire"===e.t){const t=ge.acquire({room:r,terminalId:e.terminalId});try{v.send({t:"capacity-acquire-result",reqId:e.reqId,...t})}catch{t.ok&&ge.release({room:r,terminalId:e.terminalId})}}else if("capacity-release"===e.t){if(ge.release({room:r,terminalId:e.terminalId}))for(const e of ye.values())try{e.send({t:"capacity-changed"})}catch{}}else if("restart-probe-result"===e.t){const t=Ce.get(e.nonce);if(!t||!t.pending.has(r)||ye.get(r)!==v)return;t.pending.delete(r),t.ok=t.ok&&!0===e.between,we.set(r,!0===e.between),t.pending.size||(clearTimeout(t.timer),Ce.delete(e.nonce),t.resolve(t.ok))}else if("apply-update"===e.t)Ie=!0,Ir();else if("pair-list-req"===e.t)(async()=>{if("1"===process.env.TP_CROSSROOM_OFF){try{v.send({t:"pair-list-res",reqId:e.reqId,rooms:[],error:"Cross-session reach is turned off on this bridge."})}catch{}return}const t=[...ye.keys()].filter(e=>e!==r).map(e=>({code:e,name:_e.get(e)||null}));let o=[],n=[];if("1"!==process.env.TP_PAIRBUS_OFF){const e=await Promise.all([mr(r),Se.size?ur("peer-list-req",{},1200):Promise.resolve([])]);o=e[0].rooms,n=e[1]}const s=B({authorized:o,replies:n,myUid:ce.user.id,localCodes:ye.keys()});try{v.send({t:"pair-list-res",reqId:e.reqId,rooms:[...t,...s]})}catch{}})();else if("pair-peek-req"===e.t)(async()=>{const o=r=>{try{v.send({t:"pair-peek-res",reqId:e.reqId,...r})}catch{}},n=async(r,t)=>{if(!t.verified)return o({error:`Room ${r}'s pair authorization could not be verified, so its cloud transcript was not read.`});try{const t=await withTimeout(K({sb:ae,targetRoom:r,terminal:e.terminal,lines:e.lines}),5e3,"paired cloud transcript");return o({text:t})}catch(e){return o({error:`Room ${r}'s host bridge is offline, and its cloud transcript could not be read: ${e?.message||e}`})}};if("1"===process.env.TP_CROSSROOM_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const s=String(e.targetRoom||"").toUpperCase().trim();if(!s||s===r)return o({error:`${s||"That"} is not another of your active sessions.`});const i=ye.get(s);if(i){const n=t();$e.set(n,{reply:e=>o({text:e.text,error:e.error}),askerRoom:r});try{i.send({t:"pair-peek-local",reqId:n,terminal:e.terminal,lines:e.lines})}catch{$e.delete(n),o({error:`Could not reach session ${s}.`})}return}if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const[a,c]=await Promise.all([mr(r),Se.size?ur("peer-list-req",{},800):Promise.resolve([])]);if(a.verified&&!a.rooms.some(e=>e?.code===s&&e?.owner_id!==ce.user.id))return o({error:`Room ${s} is not shared with this pair.`});if(!Se.size)return n(s,a);if(!c.some(e=>e?.rooms?.some(e=>e?.code===s)))return n(s,a);const d=await(l=s,p=e.terminal,u=e.lines,pr("peer-peek-req",{targetRoom:l,terminal:p,lines:u},M.busTimeoutMs));var l,p,u;if(!d)return n(s,a);o({text:d.text,error:d.error})})();else if("pair-post-req"===e.t)(async()=>{const o=r=>{try{v.send({t:"pair-post-res",reqId:e.reqId,...r})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-room posting is turned off on this bridge."});const n=String(e.targetRoom||"").toUpperCase().trim();if(!n||n===r)return o({error:`${n||"That"} is not another of your sessions.`});const s=ye.get(n);if(s){const i=t();$e.set(i,{reply:e=>o({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:r});try{s.send({t:"pair-post-local",reqId:i,fromRoom:r,fromHost:qe,fromTerminalName:e.fromTerminalName,text:e.text,terminal:e.terminal})}catch{$e.delete(i),o({error:`Could not reach session ${n}.`})}return}const[i,a]=await Promise.all([mr(r),Se.size?ur("peer-list-req",{},1200):Promise.resolve([])]);if(i.verified&&!i.rooms.some(e=>e?.code===n&&e?.owner_id!==ce.user.id))return o({error:`Room ${n} is not shared with this pair.`});if(!Se.size)return o({error:i.verified?`Room ${n} is paired with you, but its host bridge is offline.`:`Room ${n} is not on this machine, and no pair bus is open to reach it.`});if(!a.some(e=>e?.rooms?.some(e=>e?.code===n)))return o({error:`Room ${n} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.`});const c=await(d=n,l=e.terminal,p=e.text,u=r,m=e.fromTerminalName,pr("peer-post-req",{targetRoom:d,terminal:l,text:p,fromRoom:u,fromHost:qe,fromTerminalName:m},18e4));var d,l,p,u,m;if(!c)return o({error:`Room ${n} did not respond to the hand-off in time.`});o({ok:c.ok,ref:c.ref,error:c.error})})();else if("pair-peek-res"===e.t||"pair-post-res"===e.t){const r=$e.get(e.reqId);if(!r)return;$e.delete(e.reqId);try{r.reply(e)}catch{}}});const w=e=>{if(console.log(`child_exit sup=${Ne} room=${r} pid=${v?.pid||"?"} code=${e||"?"}`),ye.delete(r),ke.delete(r),ve.delete(r),we.delete(r),be.delete(r),_e.delete(r),Re.delete(r),ge.releaseRoom(r))for(const e of ye.values())try{e.send({t:"capacity-changed"})}catch{}for(const[e,t]of $e)t?.askerRoom===r&&$e.delete(e)};if(v.on("exit",e=>w(e)),v.on("error",e=>{w();try{process.stderr.write(`\n ◇ ${r}: failed to launch its bridge (${e?.message||e}) — retrying next tick.\n`)}catch{}}),Oe)try{v.send({t:"update-available",version:Oe})}catch{}}if("1"!==process.env.TP_PAIRBUS_OFF)for(const e of r){if(!e?.code||!ye.has(e.code))continue;const r=Array.isArray(e.participants)?e.participants:[],t=2===r.length?r.find(e=>e&&e!==ce.user.id):null;if(!t){Re.delete(e.code);continue}Re.set(e.code,t);const o=N(ce.user.id,t);o&&fr(t,o)}Ge()};await yr();const vr=setInterval(yr,15e3);Ve=!0,Ye(),c();const wr=[];let br=!1,kr=0,_r=0,$r=!1;const Rr=setInterval(async()=>{if(br)return;const e=Date.now();if(!claimLoopWedged({lastClaimOkAt:nr,now:e}))return kr=0,void(_r=0);if($r||e<_r)return;$r=!0;const r=k(kr);_r=e+r,kr+=1,console.log(`claim_watchdog sup=${Ne} quiet_ms=${e-nr} attempt=${kr} next_ms=${r} — reconnecting realtime + re-claiming`);try{ae.realtime?.connect?.()}catch{}hr=0;try{await cr(),Ge()}catch{}finally{$r=!1}},2e4);Rr.unref?.();const Sr=setInterval(()=>{if(br)return;const e=Date.now(),r=w({claimHeld:rr,lastClaimOkAt:nr,now:e});let t=!1;try{t=!!Me.presenceState?.()[qe]}catch{t=!1}const o=b({tracking:r,subscribedAt:Math.max(Je.subscribedAt,nr),syncSeenAt:Je.syncSeenAt,present:t,missingSince:sr,now:e});if(sr=o.missingSince,!sr)return void(ir=0);const n=o.verdict;if("recycle"===n){if(ir===sr)return;ir=sr,console.log(`presence_watchdog sup=${Ne} action=recycle missing_ms=${e-sr} track=${Fe} — full realtime disconnect+reconnect`);try{ae.realtime?.disconnect?.()}catch{}try{ae.realtime?.connect?.()}catch{}try{Ge()}catch{}}else"respawn"===n&&(console.log(`presence_watchdog sup=${Ne} action=respawn missing_ms=${e-sr} track=${Fe} — recycle failed, exiting for a clean supervisor respawn`),process.exit(1))},15e3);Sr.unref?.();const Tr=e=>{if(br)return;br=!0;const r=Le?1:0;console.log(`supervisor_stop sup=${Ne} reason=${e||"unknown"} restarting=${Le} children=${ye.size}`),clearInterval(vr),clearInterval(Rr),clearInterval(Sr);for(const e of wr)try{clearInterval(e)}catch{}er.cancel(),W({pid:process.pid}),releaseSingleton();for(const r of ye.values())try{r.kill(e||"SIGTERM")}catch{}dr.close();for(const{channel:e}of Se.values())try{ae.removeChannel(e)}catch{}(async()=>{try{rr&&await ae.rpc("release_bridge",{p_bridge_id:X})}catch{}try{await Me.untrack()}catch{}try{await ae.removeChannel(Ue)}catch{}try{await ae.removeChannel(Me)}catch{}process.exit(r)})(),setTimeout(()=>process.exit(r),1500)};for(const e of["SIGINT","SIGTERM","SIGHUP"])process.on(e,()=>Tr(e));async function Ir(){if(!xe&&g({pendingUpdate:Oe,stopping:br,childIdle:ve,childBetween:we,childPeer:be,requested:Ie})){if(xe=!0,!await function(e=2500){const r=[...ye.entries()];if(!r.length)return Promise.resolve(!0);const o=t();return new Promise(t=>{const n={pending:new Set(r.map(([e])=>e)),ok:!0,resolve:t,timer:null};n.timer=setTimeout(()=>{Ce.delete(o),t(!1)},e),n.timer.unref?.(),Ce.set(o,n);for(const[e,t]of r)try{t.send({t:"restart-probe",nonce:o})}catch{n.pending.delete(e),n.ok=!1}n.pending.size||(clearTimeout(n.timer),Ce.delete(o),t(n.ok))})}())return xe=!1,void process.stderr.write("\n ◆ bridge update still queued — an agent became active before the restart boundary.\n");process.stderr.write(`\n ◆ thinkpool-pair ${Oe} ready (running ${te}) — restarting account bridge to update; sessions resume.\n`);try{const e=await import("./service.mjs");if(e.serviceActive(null)&&!1!==e.applyStagedServiceUpdate(null,Oe))return}catch{}if(xe=!1,Le=!0,!me())try{e(process.execPath,[process.argv[1]],{detached:!0,stdio:"ignore",env:process.env}).unref()}catch{}Tr("SIGTERM")}}if("1"===process.env.THINKPOOL_PAIR_AUTOUPDATE&&te){const e=1e3*Math.max(60,parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL,10)||1800),r=(e,r)=>{const t=e=>String(e).split("-")[0].split(".").map(e=>parseInt(e,10)||0),o=t(e),n=t(r);for(let e=0;e<3;e++){if((o[e]||0)>(n[e]||0))return!0;if((o[e]||0)<(n[e]||0))return!1}return!1},t=async()=>{const e=new AbortController,r=setTimeout(()=>e.abort(),8e3);try{const r=await fetch("https://registry.npmjs.org/thinkpool-pair/latest",{signal:e.signal,headers:{accept:"application/json"}});if(!r.ok)return null;const t=await r.json();return"string"==typeof t?.version?t.version:null}catch{return null}finally{clearTimeout(r)}},o=async()=>{const e=await t();if(e&&r(e,te)){Oe||process.stderr.write(`\n ◆ thinkpool-pair ${e} published — "update ready" surfaced to each room; applies between turns.\n`);try{(await import("./service.mjs")).provisionRuntime(e)}catch(r){return void process.stderr.write(`\n ⚠ bridge ${e} could not be staged yet: ${r?.message||r}\n`)}Oe=e;for(const r of ye.values())try{r.send({t:"update-available",version:e})}catch{}Ir()}};wr.push(setInterval(o,e).unref()),wr.push(setInterval(Ir,15e3).unref()),wr.push(setTimeout(o,6e4).unref())}await new Promise(()=>{})}
|
|
1
|
+
import{spawn as e}from"node:child_process";import{fileURLToPath as r}from"node:url";import{randomUUID as t}from"node:crypto";import o from"node:path";import n from"node:fs";import{createClient as s}from"@supabase/supabase-js";import i from"node:os";import{saveAuth as a,loadAuth as c,markAuthReconnectRequired as d,loadDirs as l,bindDir as p,loadServed as u,rememberServed as m,loadDefaultDir as f,saveDefaultDir as h,claudeRecentProjectDir as y}from"./auth-store.mjs";import{isSafeToRestart as g}from"./update-gate.mjs";import{makeThrottledTrack as v,presenceRecoveryEligible as w,presenceSelfEchoObservation as b,recoveryBackoffMs as k,reducePresenceChannelEvidence as _,subscribeWithoutStartupDeadlock as S}from"./presence.mjs";import{publicKeyB64 as R,announceProviders as $,listProviders as T,addProvider as I,addProviderModel as O,removeProvider as x,unseal as C}from"./providers.mjs";import{supervisorServes as A,supervisorRoomsToStop as P}from"./serve-consent.mjs";import{resolveServeDir as q}from"./serve-dir.mjs";import{pairKeyFor as N,pairTopic as E,CROSSROOM_BUS as M}from"./cross-terminal.mjs";import{installedAgentCommands as U}from"./agent-detect.mjs";import{agentVisibilityStates as D,setAgentPickerVisibility as L}from"./agent-visibility.mjs";import{hostMemoryAdmission as j}from"./host-memory.mjs";import{HostCapacityAuthority as F}from"./host-capacity.mjs";import{createPairBusBroker as H,mergePairRoomRoster as B,pairBusStatusFromRealtime as z,PAIR_BUS_STATUS as G}from"./pair-bus.mjs";import{readCloudSessionTranscript as K}from"./cloud-session-transcript.mjs";import{clearSupervisorReady as J,supervisorPresenceEchoed as W,writeSupervisorReady as V}from"./supervisor-ready.mjs";import{PAIR_CLI as X,pairCli as Y}from"./command-guidance.mjs";import{syncRealtimeAuth as Z}from"./design-edit.mjs";import{crossRotationLock as Q}from"./rotation-lock.mjs";import{loadOrCreateBridgeId as ee,bridgeLabel as re}from"./bridge-identity.mjs";const te=(()=>{try{return JSON.parse(n.readFileSync(new URL("./package.json",import.meta.url),"utf8")).version}catch{return null}})(),oe=r(new URL("./bridge.mjs",import.meta.url));export function createStartupAnchor({set:e=setInterval,clear:r=clearInterval}={}){const t=e(()=>{},6e4);let o=!1;return()=>{o||(o=!0,r(t))}}const ne=o.join(i.homedir(),".thinkpool-pair","account.lock");function se(e){if(!e||e===process.pid)return!1;try{return process.kill(e,0),!0}catch(e){return"EPERM"===e.code}}export function acquireSingleton(e=ne){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{return!1}for(let r=0;r<3;r++)try{const r=n.openSync(e,"wx");return n.writeSync(r,String(process.pid)),n.closeSync(r),!0}catch(r){if("EEXIST"!==r.code)return!1;let t=0;try{t=parseInt(n.readFileSync(e,"utf8").trim(),10)||0}catch{return!1}if(se(t))return!1;try{n.rmSync(e,{force:!0})}catch{return!1}}return!1}export function releaseSingleton(e=ne){try{(parseInt(n.readFileSync(e,"utf8").trim(),10)||0)===process.pid&&n.rmSync(e,{force:!0})}catch{}}export function acquireMachineLock(e=ne){return acquireSingleton(e)}export function releaseMachineLock(e=ne){return releaseSingleton(e)}export function shouldHoldMachineLock(e=process.env){return"1"!==e.THINKPOOL_PAIR_ACCOUNT_CHILD}export async function runLogin(e,r,t){const o=s(e,r,{auth:{persistSession:!1}}),{data:n,error:i}=await o.rpc("start_device_code"),c=Array.isArray(n)?n[0]:n;!i&&c?.device_code||(process.stderr.write(`\n ✗ couldn't start login: ${i?.message||"no response"}\n`),process.exit(1));const{device_code:d,user_code:l}=c,p=`${t}/code/link?c=${encodeURIComponent(l)}`;process.stderr.write(`\n ◆ Link this device to your thinkpool account.\n Open (signed in) and approve:\n\n ${p}\n\n code: ${l}\n\n Waiting…\n`);const u=Date.now(),m=async()=>{Date.now()-u>3e5&&(process.stderr.write(`\n ◇ link timed out — run \`${Y("login")}\` again.\n`),process.exit(1));try{const{data:e}=await o.rpc("claim_device_code",{p_device_code:d});"approved"===e?.status&&e.session?.refresh_token&&(a(e.session),process.stderr.write(`\n ◆ linked as ${e.session.email||"your account"}. You can close the browser tab.\n Now run: ${X}\n`),process.exit(0)),"expired"!==e?.status&&"not_found"!==e?.status||(process.stderr.write(`\n ◇ link expired — run \`${Y("login")}\` again.\n`),process.exit(1))}catch{}setTimeout(m,3e3)};m(),await new Promise(()=>{})}const ie=e=>new Promise(r=>{const t=setTimeout(r,e);t.unref?.()});export function withTimeout(e,r,t="op"){return new Promise((o,n)=>{const s=setTimeout(()=>n(new Error(`${t} timed out after ${r}ms`)),r);s.unref?.(),Promise.resolve(e).then(e=>{clearTimeout(s),o(e)},e=>{clearTimeout(s),n(e)})})}function ae(e){try{const r=String(e).split(".")[1],t=Buffer.from(r.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"),o=JSON.parse(t)?.exp;return"number"==typeof o?o:0}catch{return 0}}export function savedTokenStillValid(e,r=Math.floor(Date.now()/1e3),t=300){if(!e?.access_token)return!1;const o=e.expires_at||ae(e.access_token);return!!o&&o-r>t}const ce=o.join(i.homedir(),".thinkpool-pair","refresh.lock");export function acquireRefreshLock(e=ce,r=3e4){try{n.mkdirSync(o.dirname(e),{recursive:!0})}catch{}for(let t=0;t<2;t++)try{const r=n.openSync(e,"wx");return n.writeSync(r,JSON.stringify({pid:process.pid,at:Date.now()})),n.closeSync(r),!0}catch(t){if("EEXIST"!==t.code)return!1;let o=1/0;try{o=Date.now()-(JSON.parse(n.readFileSync(e,"utf8"))?.at||0)}catch{try{o=Date.now()-n.statSync(e).mtimeMs}catch{return!1}}try{const{pid:r}=JSON.parse(n.readFileSync(e,"utf8"));if(Number.isInteger(r)&&r>0)try{return process.kill(r,0),!1}catch(e){if("ESRCH"!==e.code)return!1}}catch{}if(o<r)return!1;try{n.rmSync(e,{force:!0})}catch{return!1}}return!1}export function releaseRefreshLock(e=ce){try{JSON.parse(n.readFileSync(e,"utf8"))?.pid===process.pid&&n.rmSync(e,{force:!0})}catch{}}async function de(e,r){if(!r?.refresh_token||!savedTokenStillValid(r,Math.floor(Date.now()/1e3),30))return null;const t=ae(r.access_token);if(t&&t-Date.now()/1e3<=30)return null;try{const{data:t,error:o}=await e.auth.setSession({access_token:r.access_token,refresh_token:r.refresh_token});return o||!t?.session?null:t.session}catch{return null}}export async function refreshAccountSession(e,r,t={}){const{load:o=c,save:n=a,acquire:s=acquireRefreshLock,release:i=releaseRefreshLock,wait:d=ie,crossLock:l=null}=t,p=async r=>{const t=o();if(t?.refresh_token&&r&&t.refresh_token!==r){const r=await de(e,t);if(r)return{session:r,adopted:!0}}return null};let u=await p(r);if(u)return u;const m=o()?.refresh_token;if(!m)return null;let f=!1;const h=async()=>{if(l&&!f){let e=!0;try{e=await l.acquire()}catch{e=!0}if(!1===e)return!1;f=!0}return s()};let y=await h();if(!y){for(let e=0;e<20;e++){if(await d(250),u=await p(m),u)return f&&await(l?.release?.()),f=!1,u;if(await h()){y=!0;break}}if(!y)return f&&await(l?.release?.()),f=!1,null}try{if(u=await p(m),u)return u;const r=o()?.refresh_token;if(!r)return null;const{data:t,error:s}=await e.auth.refreshSession({refresh_token:r});return o()?.refresh_token!==r?await p(r):s||!t?.session?{session:null,error:s||null}:(n(t.session),{session:t.session,adopted:!1})}finally{if(f){f=!1;try{await(l?.release?.())}catch{}}y&&i()}}export const ACCOUNT_RECONNECT_ERROR_CODES=new Set(["refresh_token_not_found","refresh_token_already_used","session_not_found"]);export function accountRefreshNeedsReconnect(e){return ACCOUNT_RECONNECT_ERROR_CODES.has(String(e?.code||""))}export async function authedClient(e,r,{crossLockFor:t}={}){const o=c();if(!o?.refresh_token)return null;if(o.reconnect_required)return{reconnectRequired:!0};const n=s(e,r,{auth:{persistSession:!1,autoRefreshToken:!1}});if(savedTokenStillValid(o)){const e=await de(n,o);if(e)return{sb:n,session:e}}const i=await refreshAccountSession(n,o.refresh_token,t?{crossLock:t(n)}:{});return i?.session?{sb:n,session:i.session}:accountRefreshNeedsReconnect(i?.error)?(d(i.error.code),{reconnectRequired:!0}):null}export async function authedClientWithRetry(e,r,t={}){const{attempt:o=authedClient,load:n=c,wait:s=ie,onRetry:i=null,maxAttempts:a=1/0,baseMs:d=15e3,capMs:l=3e5,crossLockFor:p=null}=t;let u=d;for(let t=1;t<=a;t++){const c=await o(e,r,{crossLockFor:p});if(c?.reconnectRequired)return c;if(c)return c;if(!n()?.refresh_token)return null;if(t>=a)break;i?.(u,t),await s(u),u=Math.min(2*u,l)}return null}export function claimLoopWedged({lastClaimOkAt:e,now:r,quietMsThreshold:t=4e4}){return r-e>=t}export function scheduleTokenRefresh({sb:e,session:r,onRefreshed:t,onReconnectRequired:o,skewMs:n=9e4,forceMs:s=0,retryMs:i=3e4,refresh:a=refreshAccountSession,crossLock:c=null}){let d=null,l=r,p=!1,u=!1;const m=e=>{p||(clearTimeout(d),d=setTimeout(h,Math.max(1e3,e)),d?.unref&&d.unref())},f=()=>{if(s)return s;const e=l?.expires_at?1e3*l.expires_at:Date.now()+36e5;return Math.max(3e4,e-Date.now()-n)};async function h(){if(!p)try{const r=await a(e,l.refresh_token,c?{crossLock:c}:void 0);if(!r?.session){if(accountRefreshNeedsReconnect(r?.error)&&!u){u=!0;try{await(o?.(r.error))}catch{}}throw r?.error||new Error("no session")}l=r.session;try{e.realtime?.setAuth?.(l.access_token)}catch{}u=!1;try{await(t?.(l))}catch{}m(f())}catch{m(i)}}return m(f()),{cancel:()=>{p=!0,clearTimeout(d)},_tick:h}}export function runBind(e,r){const t=(e||"").toUpperCase().trim();t||(console.error(`usage: ${Y("bind","<ROOM>","<directory>")}`),process.exit(1));const s=o.resolve(r||".");n.existsSync(s)&&n.statSync(s).isDirectory()||(console.error(`✗ not a directory: ${s}`),process.exit(1)),p(t,s),console.error(`✓ bound ${t} → ${s}\n Run \`${X}\` (or restart it) to serve this session.`),process.exit(0)}export async function runAccount(r,s){acquireSingleton()||(console.error(`\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n \`${Y("<ROOM>")}\`.\n`),process.exit(0)),J();const a=createStartupAnchor(),c=ee(),X=t(),ne=e=>Q(e,c),se=await authedClientWithRetry(r,s,{crossLockFor:ne,onRetry:e=>process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(e/1e3)}s. Saved login left in place.\n`)});se||(releaseSingleton(),console.error(`\n ◇ Not linked to a thinkpool account on this machine.\n Link it: ${Y("login")}\n`),process.exit(0)),se.reconnectRequired&&(a(),releaseSingleton(),console.error("\n ◇ This bridge lost its thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n"),process.exit(0));const{sb:ie,session:ae}=se,ce=ae.user?.email||"your account";let de=ae.access_token||null;const le=process.cwd();let pe=!1;try{const e=await import("./service.mjs");pe=e.serviceActive(null)}catch{}const ue=()=>"1"===process.env.THINKPOOL_PAIR_AUTOUPDATE||pe,me="1"===process.env.THINKPOOL_PAIR_AUTOUPDATE?" (auto-updating background service — survives reboot, tracks @latest)\n":pe?" (background service — survives reboot, restarts on crash, pinned to this version)\n":` (run hands-off + auto-updating: ${Y("install-service")})\n`,fe=f()||y()||le;process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${ce}\n New sessions auto-serve from ${fe}${f()?" (your default)":y()?" (your most-recent Claude Code project)":""}\n (change it: ${Y("set-default-dir","<dir>")} · one room: ${Y("bind","<code>","<dir>")})\n${me}`);const he=new Map,ye=new F,ge=new Map,ve=new Map,we=new Map,be=new Set,ke=new Map,_e=new Map,Se=new Map,Re=new Map,$e=new Map;let Te=!1,Ie=null,Oe=!1;const xe=new Map,Ce=new Set,Ae=new Map,Pe=i.hostname().replace(/\.local$/,""),qe=(t?.()||"").slice(0,8);let Ne=null;console.log(`◇ supervisor boot sup=${qe} host=${Pe} pid=${process.pid} npx=${te||"dev"}`),await Z(ie,de,s)||process.stderr.write("\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n");const Ee=ie.channel(`tpacct:${ae.user.id}`,{config:{presence:{key:Pe},broadcast:{self:!1}}}),Me=ie.channel(`tpacct-control:${ae.user.id}`,{config:{private:!0,broadcast:{self:!1}}});let Ue=!1,De=()=>{};Me.on("broadcast",{event:"bind-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();let t=String(e?.dir||"").trim();t.startsWith("~")&&(t=o.join(i.homedir(),t.slice(1))),r&&t&&(n.existsSync(t)?(p(r,t),e?.setDefault&&(h(t),process.stderr.write(`\n ◆ default project dir set → ${t}\n`)),process.stderr.write(`\n ◆ ${r} bound → ${t} (from the web). Serving…\n`),De()):process.stderr.write(`\n ◇ bind ${r}: directory not found (${t}) — ignored.\n`))}),Me.on("broadcast",{event:"serve-room"},({payload:e})=>{const r=String(e?.code||"").toUpperCase().trim();/^[A-Z0-9]{4,12}$/.test(r)&&De()}),Me.on("broadcast",{event:"agent-output-hygiene"},({payload:e})=>{if("boolean"==typeof e?.enabled)for(const r of he.values())try{r.send({t:"agent-output-hygiene",enabled:e.enabled})}catch{}}),Me.on("broadcast",{event:"shutdown"},async()=>{if(process.stderr.write("\n ◇ disconnect requested from the dashboard — stopping bridge.\n"),setTimeout(()=>{try{releaseSingleton()}catch{}process.exit(0)},2500),ue())try{(await import("./service.mjs")).uninstallService(null)}catch{}process.kill(process.pid,"SIGTERM")});let Le=!1;Me.on("broadcast",{event:"restart"},async({payload:e}={})=>{const r=e?.nonce,t=async(e,t)=>{try{await Me.send({type:"broadcast",event:"restart-status",payload:{nonce:r,ok:e,error:t}})}catch{}};if(Le||Oe)await t(!1,"a bridge update is already being prepared");else{Le=!0;try{const r=await import("./service.mjs"),o=r.serviceActive(null),n=o?r.stageServiceUpdate(null):null;if(o&&!n)return void await t(!1,"the published runtime could not be staged; the existing bridge was left running");Ie=n||("string"==typeof e?.version?e.version:te)||"restart",Te=!0,await t(!0)}catch(e){return void await t(!1,`restart preparation failed: ${e?.message||e}`)}finally{Le=!1}Tr()}});let je="init",Fe="connected";const He=v(Ee,{minMs:5e3,onStatus:e=>{je=e}}),Be=R(),ze=()=>He({name:Pe,bridgeId:X,version:te,rooms:[...he.keys()],readyRooms:[...be],refused:[...Ae].map(([e,r])=>({code:e,reason:r})),service:ue(),providerPubKey:Be,providers:$(),agents:D(U()),accountAuthState:Fe,ts:Date.now()}),Ge=async e=>{try{await withTimeout(ie.rpc("report_bridge_auth_state",{p_bridge_id:c,p_state:e}),5e3,"report_bridge_auth_state")}catch{}},Ke=(e,r)=>{try{Me.send({type:"broadcast",event:e,payload:r})}catch{}};Me.on("broadcast",{event:"master-brief-cleanup"},({payload:e})=>{const r=String(e?.nonce||""),t=String(e?.transcript||"").trim();if(!r||!t||t.length>12e3)return void Ke("master-brief-cleanup-res",{nonce:r,ok:!1,error:"That recording could not be cleaned up."});const o=[...be].find(e=>!1!==he.get(e)?.connected),n=o?he.get(o):null;if(n)try{n.send({t:"master-brief-cleanup",nonce:r,transcript:t,speaker:e?.speaker})}catch{Ke("master-brief-cleanup-res",{nonce:r,ok:!1,error:"The connected agent was unavailable."})}else Ke("master-brief-cleanup-res",{nonce:r,ok:!1,error:"Open a session with a connected agent to clean this Brief."})}),Me.on("broadcast",{event:"provider-add"},async({payload:e})=>{const r=e?.nonce;let t;try{t=C(e?.sealed)}catch{return Ke("provider-add-res",{nonce:r,ok:!1,error:"could not decrypt the sealed payload"})}const o=I(t);o.ok&&(ze(),process.stderr.write(`\n ◆ provider added (${String(t?.name||"").slice(0,40)}) — re-announced.\n`)),Ke("provider-add-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,id:o.ok?o.id:void 0})}),Me.on("broadcast",{event:"provider-add-model"},async({payload:e})=>{const r=e?.nonce,t=O({id:e?.id,model:e?.model,name:e?.name});t.ok&&(ze(),process.stderr.write(`\n ◆ model added to an existing provider (${String(e?.model||"").slice(0,40)}) — re-announced.\n`)),Ke("provider-add-model-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error,id:t.ok?t.id:void 0})}),Me.on("broadcast",{event:"provider-remove"},async({payload:e})=>{const r=e?.nonce,t=x(e?.id);t.ok&&(ze(),process.stderr.write("\n ◆ provider removed — re-announced.\n")),Ke("provider-remove-res",{nonce:r,ok:t.ok,error:t.ok?void 0:t.error})}),Me.on("broadcast",{event:"providers-list-req"},async({payload:e})=>{const r=e?.nonce;Ke("providers-list-res",{nonce:r,ok:!0,providers:T()})}),Me.on("broadcast",{event:"agent-visibility-set"},async({payload:e})=>{const r=e?.nonce,t=U(),o=L(e?.cmd,!0===e?.enabled,{installed:t});if(o.ok){ze();for(const e of he.values())try{e.send({t:"agent-visibility-changed"})}catch{}process.stderr.write(`\n ◆ terminal picker agents updated — ${o.agents.filter(e=>e.enabled).map(e=>e.cmd).join(", ")} visible.\n`)}Ke("agent-visibility-res",{nonce:r,ok:o.ok,error:o.ok?void 0:o.error,agents:o.ok?o.agents:void 0})});let Je=_(),We=!1,Ve=!1;const Xe=()=>{if(Ve||!We)return;let e=!1;try{e=W(Ee.presenceState?.(),Pe,X,te)}catch{e=!1}e&&(Ve=V({version:te,bridgeId:X}),Ve||process.stderr.write("\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n"))};Ee.on("presence",{event:"sync"},()=>{Je=_(Je,"sync"),Xe()});const Ye=await S(Ee,e=>{if("SUBSCRIBED"===e)Je=_(Je,e),ze();else if("CLOSED"===e||"CHANNEL_ERROR"===e||"TIMED_OUT"===e){Je=_(Je,e);try{ie.realtime?.connect?.()}catch{}}});Ye.connected||process.stderr.write(`\n ◇ account realtime not ready at startup (${Ye.status}) — staying alive and recovering in process.\n`);const Ze=await S(Me,e=>{"CLOSED"!==e&&"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ⚠ private account control ${e}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)});Ze.connected||process.stderr.write(`\n ◇ private account control not ready at startup (${Ze.status}) — presence remains available.\n`);const Qe=scheduleTokenRefresh({sb:ie,session:ae,crossLock:ne(ie),onRefreshed:async e=>{Fe="connected",ze(),await Ge("connected"),de=e.access_token||de;for(const e of he.values())try{e.send({t:"token",accessToken:de})}catch{}},onReconnectRequired:async e=>{Fe="reconnect-required",d(e?.code||"refresh-token-invalid"),ze(),await Ge("reconnect-required"),process.stderr.write("\n ◇ This bridge lost its thinkpool account link.\n Reconnect it: npx thinkpool-pair@latest login\n")},forceMs:parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS,10)||0});let er=!1,rr=0,tr=null,or=Date.now(),nr=0,sr=0,ir="";const ar=async()=>{try{const{data:e}=await withTimeout(ie.rpc("claim_bridge",{p_bridge_id:c,p_hostname:Pe}),1e4,"claim_bridge");or=Date.now();const r=Array.isArray(e)?e[0]:e,t=!!r?.held,o=r?.holder_host||"?";return t&&!er?process.stderr.write("\n ◆ holding this account — serving your sessions (single active bridge).\n"):!t&&er?process.stderr.write(`\n ◇ another device (${o}) took over this account — standing by.\n`):t||tr===o||process.stderr.write(`\n ◇ another device (${o}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`),er=t,rr=t&&Number.isSafeInteger(Number(r?.fencing))&&Number(r.fencing)>0?Number(r.fencing):0,tr=t?null:o,Ne!==t&&(console.log(`claim_tick sup=${qe} held=${t} holder=${t?Pe:o}`),Ne=t),t}catch{return er}},cr=H({channels:Re,randomId:t}),dr=e=>cr.resolve(e),lr=(e,r,t)=>cr.requestFirst(e,r,t),pr=(e,r,t)=>cr.requestCollect(e,r,t),ur=async e=>{try{const r=await withTimeout(ie.rpc("list_pair_rooms",{p_code:e}),2500,"paired-room roster");return r?.error||!Array.isArray(r?.data)?{verified:!1,rooms:[]}:{verified:!0,rooms:r.data}}catch{return{verified:!1,rooms:[]}}},mr=(e,r)=>{if(!e||Re.has(e))return;const o=E(r);if(!o)return;const n=ie.channel(o,{config:{private:!0,broadcast:{self:!1}}}),s={channel:n,key:r,status:G.JOINING};n.on("broadcast",{event:"peer-list-req"},({payload:r})=>((e,r,t)=>{if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return;const o=[...he.keys()].filter(r=>Se.get(r)===e).map(e=>({code:e,name:ke.get(e)||null,host:Pe}));try{r.send({type:"broadcast",event:"peer-list-res",payload:{nonce:t?.nonce,rooms:o}})}catch{}})(e,n,r)).on("broadcast",{event:"peer-list-res"},({payload:e})=>dr(e)).on("broadcast",{event:"peer-peek-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-peek-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF||"1"===process.env.TP_CROSSROOM_OFF)return n({error:"Cross-session reach is off on this device."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=he.get(s);if(!i||Se.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();_e.set(a,{reply:e=>n({text:e.text,error:e.error}),askerRoom:null});try{i.send({t:"pair-peek-local",reqId:a,terminal:o.terminal,lines:o.lines})}catch{_e.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-peek-res"},({payload:e})=>dr(e)).on("broadcast",{event:"peer-post-req"},({payload:r})=>((e,r,o)=>{const n=e=>{try{r.send({type:"broadcast",event:"peer-post-res",payload:{nonce:o?.nonce,...e}})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return n({error:"Cross-room posting is off on this device."});if(!(e=>{const r=Date.now();let t=$e.get(e);return(!t||r>=t.resetAt)&&(t={count:0,resetAt:r+6e4},$e.set(e,t)),!(t.count>=6||(t.count++,0))})(e))return n({error:"Too many cross-room posts to this device right now — try again shortly."});const s=String(o?.targetRoom||"").toUpperCase().trim(),i=he.get(s);if(!i||Se.get(s)!==e)return n({error:`Room ${s} is not a room this device shares with you.`});const a=t();_e.set(a,{reply:e=>n({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:null});try{i.send({t:"pair-post-local",reqId:a,fromRoom:o.fromRoom,fromHost:o.fromHost,fromTerminalName:o.fromTerminalName,text:o.text,terminal:o.terminal})}catch{_e.delete(a),n({error:`Could not reach room ${s}.`})}})(e,n,r)).on("broadcast",{event:"peer-post-res"},({payload:e})=>dr(e)).subscribe(e=>{s.status=z(e),"SUBSCRIBED"===e?process.stderr.write("\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n"):"CHANNEL_ERROR"!==e&&"TIMED_OUT"!==e||process.stderr.write(`\n ◇ pair bus ${e.toLowerCase().replace("_"," ")} — partner sessions are offline until it reconnects.\n`)}),Re.set(e,s)};let fr=0;const hr=async()=>{const e=Date.now();if(!(fr&&e-fr<6e4)){fr=e;try{await yr()}finally{fr===e&&(fr=0)}}};De=()=>{hr().catch(()=>{})};const yr=async()=>{if(await(async()=>{try{const{data:e}=await withTimeout(ie.rpc("enroll_bridge",{p_bridge_id:c,p_label:re(),p_version:te}),1e4,"enroll_bridge"),r=Array.isArray(e)?e[0]:e,t=r?.reason||"";t!==ir&&("revoked"===t?process.stderr.write("\n ◇ this device was revoked from your thinkpool account — standing by.\n Restore it from the account page to serve again.\n"):"foreign"===t&&process.stderr.write("\n ◇ this machine's bridge identity belongs to a different thinkpool account.\n Remove ~/.thinkpool-pair/bridge-id and relink to enroll fresh.\n"),ir=t)}catch{}})(),!await ar()){if(he.size)for(const[,e]of he)try{e.kill("SIGTERM")}catch{}return void ze()}let r=[],s=!1;const a=ae.user.id;try{const{data:e,error:t}=await ie.from("code_sessions").select("code,name,last_active_at,owner_id,participants,closed_at").eq("owner_id",a).is("closed_at",null).order("last_active_at",{ascending:!1}).limit(20);if(t)throw t;r=e||[],s=!0;const o=new Set(r.map(e=>e.code));for(const e of[...Ae.keys()])o.has(e)||Ae.delete(e)}catch{}if(s)for(const e of P({rooms:r,childRooms:he.keys(),myUid:a})){const r=he.get(e);if(r){console.log(`child_retire sup=${qe} room=${e} pid=${r.pid||"?"} reason=not-active-owned`);try{r.kill("SIGTERM")}catch{}}}const d=l(),p=u();for(const s of r){const r=s.code;if(!r||he.has(r)){r&&Ae.delete(r);continue}if(!A({ownerId:s.owner_id,myUid:a,closedAt:s.closed_at}))continue;const l=q({bound:d[r],remembered:p[r],defaultDir:f()||y()||le,home:i.homedir()});if(l.refuse){if(Ae.set(r,l.reason),!Ce.has(r)){Ce.add(r);const e="home"===l.reason?`no project directory set — refusing to serve it from your home folder (${l.dir}); the agent would lose its session context and the room would show "${o.basename(l.dir)}" as the repo.`:"no directory to serve it from.";process.stderr.write(`\n ◇ ${r}: ${e}\n Bind it to its repo: ${Y("bind",r,"<repo-dir>")}\n`)}continue}const u=l.dir;if(!n.existsSync(u)){Ae.set(r,"dir-gone"),Ce.has(r)||(Ce.add(r),process.stderr.write(`\n ◇ ${r}: bound directory is gone (${u}) — re-bind it.\n`));continue}Ce.delete(r),Ae.delete(r),l.fromDefault&&m(r,u),ke.set(r,s.name||null),process.stderr.write(`\n ◆ serving ${r}${s.name?` "${s.name}"`:""} → ${u}${d[r]?"":" (default)"}\n`);const h={...process.env,THINKPOOL_PAIR_ACCOUNT_CHILD:"1",TP_ACCOUNT_BRIDGE_ID:c,TP_ACCOUNT_BRIDGE_FENCING:String(rr)};delete h.THINKPOOL_PAIR_AUTOUPDATE,de&&(h.TP_ACCESS_TOKEN=de);const g=he.has(r),v=j(`spawn the room bridge for ${r}`);if(!v.ok){Ce.has(`memory:${r}`)||(Ce.add(`memory:${r}`),process.stderr.write(`\n ◇ ${r}: ${v.reason}\n`)),Ae.set(r,"host-memory");continue}Ce.delete(`memory:${r}`);const w=e(process.execPath,[oe,r,"--headless","--auto=claude"],{cwd:u,stdio:["inherit","inherit","inherit","ipc"],env:h});console.log(`child_spawn sup=${qe} room=${r} pid=${w.pid} dir=${u} had_prior=${g}`),he.set(r,w),be.delete(r),ge.set(r,!1),ve.set(r,!1),we.set(r,!1),w.on("message",e=>{if(e)if("room-ready"===e.t)be.add(r),console.log(`child_ready sup=${qe} room=${r} pid=${w.pid}`),ze();else if("idle"===e.t)ge.set(r,!!e.idle),ve.set(r,null!=e.between?!!e.between:!!e.idle),we.set(r,!!e.webPeer),Tr();else if("busy"===e.t)ge.set(r,!1),ve.set(r,!1);else if("master-brief-cleanup-res"===e.t)Ke("master-brief-cleanup-res",{nonce:e.nonce,ok:!0===e.ok,...e.brief?{brief:e.brief}:{},...e.error?{error:e.error}:{}});else if("capacity-acquire"===e.t){const t=ye.acquire({room:r,terminalId:e.terminalId});try{w.send({t:"capacity-acquire-result",reqId:e.reqId,...t})}catch{t.ok&&ye.release({room:r,terminalId:e.terminalId})}}else if("capacity-release"===e.t){if(ye.release({room:r,terminalId:e.terminalId}))for(const e of he.values())try{e.send({t:"capacity-changed"})}catch{}}else if("restart-probe-result"===e.t){const t=xe.get(e.nonce);if(!t||!t.pending.has(r)||he.get(r)!==w)return;t.pending.delete(r),t.ok=t.ok&&!0===e.between,ve.set(r,!0===e.between),t.pending.size||(clearTimeout(t.timer),xe.delete(e.nonce),t.resolve(t.ok))}else if("apply-update"===e.t)Te=!0,Tr();else if("pair-list-req"===e.t)(async()=>{if("1"===process.env.TP_CROSSROOM_OFF){try{w.send({t:"pair-list-res",reqId:e.reqId,rooms:[],error:"Cross-session reach is turned off on this bridge."})}catch{}return}const t=[...he.keys()].filter(e=>e!==r).map(e=>({code:e,name:ke.get(e)||null}));let o=[],n=[];if("1"!==process.env.TP_PAIRBUS_OFF){const e=await Promise.all([ur(r),Re.size?pr("peer-list-req",{},1200):Promise.resolve([])]);o=e[0].rooms,n=e[1]}const s=B({authorized:o,replies:n,myUid:ae.user.id,localCodes:he.keys()});try{w.send({t:"pair-list-res",reqId:e.reqId,rooms:[...t,...s]})}catch{}})();else if("pair-peek-req"===e.t)(async()=>{const o=r=>{try{w.send({t:"pair-peek-res",reqId:e.reqId,...r})}catch{}},n=async(r,t)=>{if(!t.verified)return o({error:`Room ${r}'s pair authorization could not be verified, so its cloud transcript was not read.`});try{const t=await withTimeout(K({sb:ie,targetRoom:r,terminal:e.terminal,lines:e.lines}),5e3,"paired cloud transcript");return o({text:t})}catch(e){return o({error:`Room ${r}'s host bridge is offline, and its cloud transcript could not be read: ${e?.message||e}`})}};if("1"===process.env.TP_CROSSROOM_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const s=String(e.targetRoom||"").toUpperCase().trim();if(!s||s===r)return o({error:`${s||"That"} is not another of your active sessions.`});const i=he.get(s);if(i){const n=t();_e.set(n,{reply:e=>o({text:e.text,error:e.error}),askerRoom:r});try{i.send({t:"pair-peek-local",reqId:n,terminal:e.terminal,lines:e.lines})}catch{_e.delete(n),o({error:`Could not reach session ${s}.`})}return}if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-session reach is turned off on this bridge."});const[a,c]=await Promise.all([ur(r),Re.size?pr("peer-list-req",{},800):Promise.resolve([])]);if(a.verified&&!a.rooms.some(e=>e?.code===s&&e?.owner_id!==ae.user.id))return o({error:`Room ${s} is not shared with this pair.`});if(!Re.size)return n(s,a);if(!c.some(e=>e?.rooms?.some(e=>e?.code===s)))return n(s,a);const d=await(l=s,p=e.terminal,u=e.lines,lr("peer-peek-req",{targetRoom:l,terminal:p,lines:u},M.busTimeoutMs));var l,p,u;if(!d)return n(s,a);o({text:d.text,error:d.error})})();else if("pair-post-req"===e.t)(async()=>{const o=r=>{try{w.send({t:"pair-post-res",reqId:e.reqId,...r})}catch{}};if("1"===process.env.TP_PAIRBUS_OFF)return o({error:"Cross-room posting is turned off on this bridge."});const n=String(e.targetRoom||"").toUpperCase().trim();if(!n||n===r)return o({error:`${n||"That"} is not another of your sessions.`});const s=he.get(n);if(s){const i=t();_e.set(i,{reply:e=>o({ok:e.ok,ref:e.ref,error:e.error}),askerRoom:r});try{s.send({t:"pair-post-local",reqId:i,fromRoom:r,fromHost:Pe,fromTerminalName:e.fromTerminalName,text:e.text,terminal:e.terminal})}catch{_e.delete(i),o({error:`Could not reach session ${n}.`})}return}const[i,a]=await Promise.all([ur(r),Re.size?pr("peer-list-req",{},1200):Promise.resolve([])]);if(i.verified&&!i.rooms.some(e=>e?.code===n&&e?.owner_id!==ae.user.id))return o({error:`Room ${n} is not shared with this pair.`});if(!Re.size)return o({error:i.verified?`Room ${n} is paired with you, but its host bridge is offline.`:`Room ${n} is not on this machine, and no pair bus is open to reach it.`});if(!a.some(e=>e?.rooms?.some(e=>e?.code===n)))return o({error:`Room ${n} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.`});const c=await(d=n,l=e.terminal,p=e.text,u=r,m=e.fromTerminalName,lr("peer-post-req",{targetRoom:d,terminal:l,text:p,fromRoom:u,fromHost:Pe,fromTerminalName:m},18e4));var d,l,p,u,m;if(!c)return o({error:`Room ${n} did not respond to the hand-off in time.`});o({ok:c.ok,ref:c.ref,error:c.error})})();else if("pair-peek-res"===e.t||"pair-post-res"===e.t){const r=_e.get(e.reqId);if(!r)return;_e.delete(e.reqId);try{r.reply(e)}catch{}}});const b=e=>{if(console.log(`child_exit sup=${qe} room=${r} pid=${w?.pid||"?"} code=${e||"?"}`),he.delete(r),be.delete(r),ge.delete(r),ve.delete(r),we.delete(r),ke.delete(r),Se.delete(r),ye.releaseRoom(r))for(const e of he.values())try{e.send({t:"capacity-changed"})}catch{}for(const[e,t]of _e)t?.askerRoom===r&&_e.delete(e)};if(w.on("exit",e=>b(e)),w.on("error",e=>{b();try{process.stderr.write(`\n ◇ ${r}: failed to launch its bridge (${e?.message||e}) — retrying next tick.\n`)}catch{}}),Ie)try{w.send({t:"update-available",version:Ie})}catch{}}if("1"!==process.env.TP_PAIRBUS_OFF)for(const e of r){if(!e?.code||!he.has(e.code))continue;const r=Array.isArray(e.participants)?e.participants:[],t=2===r.length?r.find(e=>e&&e!==ae.user.id):null;if(!t){Se.delete(e.code);continue}Se.set(e.code,t);const o=N(ae.user.id,t);o&&mr(t,o)}ze()};await hr();const gr=setInterval(hr,15e3);We=!0,Xe(),a();const vr=[];let wr=!1,br=0,kr=0,_r=!1;const Sr=setInterval(async()=>{if(wr)return;const e=Date.now();if(!claimLoopWedged({lastClaimOkAt:or,now:e}))return br=0,void(kr=0);if(_r||e<kr)return;_r=!0;const r=k(br);kr=e+r,br+=1,console.log(`claim_watchdog sup=${qe} quiet_ms=${e-or} attempt=${br} next_ms=${r} — reconnecting realtime + re-claiming`);try{ie.realtime?.connect?.()}catch{}fr=0;try{await ar(),ze()}catch{}finally{_r=!1}},2e4);Sr.unref?.();const Rr=setInterval(()=>{if(wr)return;const e=Date.now(),r=w({claimHeld:er,lastClaimOkAt:or,now:e});let t=!1;try{t=!!Ee.presenceState?.()[Pe]}catch{t=!1}const o=b({tracking:r,subscribedAt:Math.max(Je.subscribedAt,or),syncSeenAt:Je.syncSeenAt,present:t,missingSince:nr,now:e});if(nr=o.missingSince,!nr)return void(sr=0);const n=o.verdict;if("recycle"===n){if(sr===nr)return;sr=nr,console.log(`presence_watchdog sup=${qe} action=recycle missing_ms=${e-nr} track=${je} — full realtime disconnect+reconnect`);try{ie.realtime?.disconnect?.()}catch{}try{ie.realtime?.connect?.()}catch{}try{ze()}catch{}}else"respawn"===n&&(console.log(`presence_watchdog sup=${qe} action=respawn missing_ms=${e-nr} track=${je} — recycle failed, exiting for a clean supervisor respawn`),process.exit(1))},15e3);Rr.unref?.();const $r=e=>{if(wr)return;wr=!0;const r=Ue?1:0;console.log(`supervisor_stop sup=${qe} reason=${e||"unknown"} restarting=${Ue} children=${he.size}`),clearInterval(gr),clearInterval(Sr),clearInterval(Rr);for(const e of vr)try{clearInterval(e)}catch{}Qe.cancel(),J({pid:process.pid}),releaseSingleton();for(const r of he.values())try{r.kill(e||"SIGTERM")}catch{}cr.close();for(const{channel:e}of Re.values())try{ie.removeChannel(e)}catch{}(async()=>{try{er&&await ie.rpc("release_bridge",{p_bridge_id:c})}catch{}try{await Ee.untrack()}catch{}try{await ie.removeChannel(Me)}catch{}try{await ie.removeChannel(Ee)}catch{}process.exit(r)})(),setTimeout(()=>process.exit(r),1500)};for(const e of["SIGINT","SIGTERM","SIGHUP"])process.on(e,()=>$r(e));async function Tr(){if(!Oe&&g({pendingUpdate:Ie,stopping:wr,childIdle:ge,childBetween:ve,childPeer:we,requested:Te})){if(Oe=!0,!await function(e=2500){const r=[...he.entries()];if(!r.length)return Promise.resolve(!0);const o=t();return new Promise(t=>{const n={pending:new Set(r.map(([e])=>e)),ok:!0,resolve:t,timer:null};n.timer=setTimeout(()=>{xe.delete(o),t(!1)},e),n.timer.unref?.(),xe.set(o,n);for(const[e,t]of r)try{t.send({t:"restart-probe",nonce:o})}catch{n.pending.delete(e),n.ok=!1}n.pending.size||(clearTimeout(n.timer),xe.delete(o),t(n.ok))})}())return Oe=!1,void process.stderr.write("\n ◆ bridge update still queued — an agent became active before the restart boundary.\n");process.stderr.write(`\n ◆ thinkpool-pair ${Ie} ready (running ${te}) — restarting account bridge to update; sessions resume.\n`);try{const e=await import("./service.mjs");if(e.serviceActive(null)&&!1!==e.applyStagedServiceUpdate(null,Ie))return}catch{}if(Oe=!1,Ue=!0,!ue())try{e(process.execPath,[process.argv[1]],{detached:!0,stdio:"ignore",env:process.env}).unref()}catch{}$r("SIGTERM")}}if("1"===process.env.THINKPOOL_PAIR_AUTOUPDATE&&te){const e=1e3*Math.max(60,parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL,10)||1800),r=(e,r)=>{const t=e=>String(e).split("-")[0].split(".").map(e=>parseInt(e,10)||0),o=t(e),n=t(r);for(let e=0;e<3;e++){if((o[e]||0)>(n[e]||0))return!0;if((o[e]||0)<(n[e]||0))return!1}return!1},t=async()=>{const e=new AbortController,r=setTimeout(()=>e.abort(),8e3);try{const r=await fetch("https://registry.npmjs.org/thinkpool-pair/latest",{signal:e.signal,headers:{accept:"application/json"}});if(!r.ok)return null;const t=await r.json();return"string"==typeof t?.version?t.version:null}catch{return null}finally{clearTimeout(r)}},o=async()=>{const e=await t();if(e&&r(e,te)){Ie||process.stderr.write(`\n ◆ thinkpool-pair ${e} published — "update ready" surfaced to each room; applies between turns.\n`);try{(await import("./service.mjs")).provisionRuntime(e)}catch(r){return void process.stderr.write(`\n ⚠ bridge ${e} could not be staged yet: ${r?.message||r}\n`)}Ie=e;for(const r of he.values())try{r.send({t:"update-available",version:e})}catch{}Tr()}};vr.push(setInterval(o,e).unref()),vr.push(setInterval(Tr,15e3).unref()),vr.push(setTimeout(o,6e4).unref())}await new Promise(()=>{})}
|
package/browser-job.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import o from"node:net";import{setTimeout as t}from"node:timers/promises";export const BROWSER_JOB_PORT=47683;export async function acquireBrowserJob({port:r=47683,timeoutMs:e=12e4,signal:s}={}){if(!Number.isInteger(r)||r<1||r>65535)throw new Error("Invalid browser job port.");const i=Date.now()+e;for(;;){s?.throwIfAborted();const e=o.createServer(o=>o.destroy());try{await new Promise((o,t)=>{e.once("error",t),e.listen({host:"127.0.0.1",port:r,exclusive:!0},o)})}catch(o){if(e.close(),"EADDRINUSE"!==o.code)throw o;if(Date.now()>=i)throw new Error("Browser job queue timed out. Another job still holds this host’s browser slot.");await t(Math.min(50,Math.max(1,i-Date.now())),void 0,{signal:s});continue}let n=!1;const a=async()=>{n||(n=!0,await new Promise((o,t)=>e.close(r=>r?t(r):o())))};return s?.aborted&&(await a(),s.throwIfAborted()),a}}export async function withBrowserJob(o,t){const r=await acquireBrowserJob(t);try{return await o()}finally{await r()}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.449",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a thinkpool Code room.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
"flow-preview.mjs",
|
|
128
128
|
"mockup-delivery.mjs",
|
|
129
129
|
"viewport.mjs",
|
|
130
|
+
"browser-job.mjs",
|
|
130
131
|
"design-edit.mjs",
|
|
131
132
|
"realtime-auth.mjs",
|
|
132
133
|
"design-source-contract.mjs",
|
package/publish-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": 1,
|
|
3
3
|
"package": "thinkpool-pair",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.449",
|
|
5
5
|
"kind": "minified",
|
|
6
|
-
"minifiedModules":
|
|
7
|
-
"sourceModuleBytes":
|
|
8
|
-
"artifactModuleBytes":
|
|
6
|
+
"minifiedModules": 150,
|
|
7
|
+
"sourceModuleBytes": 1977347,
|
|
8
|
+
"artifactModuleBytes": 903682,
|
|
9
9
|
"files": {
|
|
10
|
-
"package.json": "
|
|
10
|
+
"package.json": "f1ed6ad7eb2268a56226686c41cbc25a0a4a25e5c3506a6734ea4f151c6bea2e",
|
|
11
11
|
"LICENSE": "8a030197ca1cad9669909ca14c9ad15cf48d9fe551da526ccaf1fca993eddb23",
|
|
12
12
|
"bridge.mjs": "1bab3b6045603450a92eb3199568e05a8842a18dcfb31fdbe93d413c7fb1ae7f",
|
|
13
13
|
"bridge-service.sh": "b64f27c473ca8f3a49190fd08c23947ab14ca9958d70b6fa53c7bddc2be91403",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"question-response.mjs": "35a7ba53d3eb117717c18004d2a8441f8f37665b231a3c44f4bd9321ff59dd57",
|
|
36
36
|
"thinkpool-room-prompt.mjs": "cbae9bba173afad90eec21cfc726baec72cc26de4e5b96c25098755fc61f02c8",
|
|
37
37
|
"thinkpool-prompt-contracts.mjs": "051602d7d2fe9b34df92957bd3922c5eeebedbcc60c892adf415243887d8c62f",
|
|
38
|
-
"thinkpool-capabilities.json": "
|
|
38
|
+
"thinkpool-capabilities.json": "9a47bb1e0cf69f8ca0af9dc7685aa9ac6745393900c46e22274d3eedb8626905",
|
|
39
39
|
"room-file-delivery.mjs": "a33300b7f12152e8d016771aabbd4616896dec4bf7a3762e059d271e107a1b6a",
|
|
40
40
|
"codex-app-server.mjs": "f49c4f6c15766765074b014eee11e9399f6c540420a1157f6e9ad79a432b18e3",
|
|
41
41
|
"codex-images.mjs": "31605d6d8b3dd6b73395da63d1b0c513445c95ffa2f4306ee466f201d633ffc0",
|
|
@@ -126,7 +126,8 @@
|
|
|
126
126
|
"flow-host-revert.mjs": "a6da239fc54723c74ed51fc03ae3203458b92d2a2e33c26576a9e62cd1031fea",
|
|
127
127
|
"flow-preview.mjs": "d38cb7f292d41600cde85eef335b0793f5bad7583f9f851f886ef48c4a91b4f1",
|
|
128
128
|
"mockup-delivery.mjs": "fa112408a186d856b343157381188dc18502aba6cf7ef365459f6c5a074e9cd6",
|
|
129
|
-
"viewport.mjs": "
|
|
129
|
+
"viewport.mjs": "cdbf8f6617e8b840c1317b91580eff8d2c7f86d3d476651bcb1bdd657f23c101",
|
|
130
|
+
"browser-job.mjs": "391f92c293ba6da419089eb7eaaf942ba44339d1bdba87bb56e23e51d1e94fd0",
|
|
130
131
|
"design-edit.mjs": "23df0a89b8f6f4df19b7eac55e3bee0ef277c9cddd632d9c3bd0197b2a35059e",
|
|
131
132
|
"realtime-auth.mjs": "f18260716dbca71f420cafac7f09ce435df56de217174ee50196e9be9cf93ecc",
|
|
132
133
|
"design-source-contract.mjs": "18bcd024b317293e445c433fc9c59f9eb85e8fb3fa4de824b82604bbae4687b8",
|
|
@@ -153,7 +154,7 @@
|
|
|
153
154
|
"service.mjs": "51472fdda8fd249620924c5b9515bec2d754483bebae29fbe0cb4b02a59ecbed",
|
|
154
155
|
"supervisor-ready.mjs": "7d5e4ebe3c7a93e0888dd335fb4556a670e55a80a89b861c8a44feac159cd840",
|
|
155
156
|
"presence.mjs": "2516ebb4474a8f45dd881530335c2174af9cde44be8eed6896ff8c62497b6e2b",
|
|
156
|
-
"account.mjs": "
|
|
157
|
+
"account.mjs": "06af4bbd7719cb1552f5ef445f7edb47c09dd4c807c812fe8610e0f2503900c9",
|
|
157
158
|
"auth-store.mjs": "c4d41ee7cdc34c2002c6dd50d32e53ace0a3f03a116ffa930ed94a437e247625",
|
|
158
159
|
"key-shape.mjs": "a4113293d52eeba39b05e70a3e0345abc1ddc181b4f236b413bde0ae11a3f646",
|
|
159
160
|
"supabase-key.mjs": "041bde6cf50b15f6426daaf738374abc9647861d8c1dd3d85cedc45684271888",
|
|
@@ -161,7 +162,7 @@
|
|
|
161
162
|
"providers.mjs": "c0757433d30bf2f22e80f85e779cdeeeb10b412d568965f1115d4de2c8aa0314",
|
|
162
163
|
"provider-resilience.mjs": "a5a3921040d1860bb82f1d3b53c6ec25a5e230345c0131f2213c535e07fc497e",
|
|
163
164
|
"model-prices.mjs": "91f0bd8688f59ce6bff7743710d3b1139f4f0823d6b95fa68cd9b6fdcdba6ab4",
|
|
164
|
-
"README.md": "
|
|
165
|
+
"README.md": "9f423baed047976763c8fbcab81366ba0ce5872844101d8b123d6cdfb375895b",
|
|
165
166
|
"publish-guard.mjs": "569dd788790e68ea822129f588fd16c1bb435324666552fe9dfe7c435ab3f617"
|
|
166
167
|
}
|
|
167
168
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 45,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -182,24 +182,51 @@
|
|
|
182
182
|
},
|
|
183
183
|
{
|
|
184
184
|
"id": "visual-proof",
|
|
185
|
-
"version":
|
|
185
|
+
"version": 8,
|
|
186
186
|
"routes": [
|
|
187
187
|
{
|
|
188
188
|
"id": "visual-proof",
|
|
189
|
-
"tools": [
|
|
189
|
+
"tools": [
|
|
190
|
+
"preview_start",
|
|
191
|
+
"preview_capture",
|
|
192
|
+
"preview_inspect",
|
|
193
|
+
"preview_stop"
|
|
194
|
+
],
|
|
190
195
|
"trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
|
|
191
|
-
"prompt": "For
|
|
196
|
+
"prompt": "For visual work, build, use preview_start, preview_capture at desktop and mobile, preview_inspect for rendered state, and preview_stop when finished. Omit path for the authenticated room; / targets the public landing page. For application-preview Design cards, use the source-aware Design build command when available; otherwise build normally with selector-based source matching labeled as fallback. Managed previews share one host job slot, reuse a browser, and close it after 60 seconds idle. Each job has an isolated context. Mobile enables touch and mobile layout; Safari/device checks are separate. Captures are inline verification evidence by default and must not create a transcript card. Set card=true only for a requested mockup/Design artifact or intentional editable visual deliverable. Cards require settled desktop+mobile content, reject loading/public-auth fallback shells, and arrive after the final response. Room/protected previews use the bridge account's read-only authenticated harness: external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked; non-mutating roster/transcript snapshots are allowed. Show PNGs only when no interactive source-backed Design card is displayed."
|
|
192
197
|
}
|
|
193
198
|
],
|
|
194
199
|
"impact": [
|
|
195
|
-
{
|
|
196
|
-
|
|
200
|
+
{
|
|
201
|
+
"path": "bridge/viewport.mjs"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"path": ".claude/skills/mockup-iterate/scripts/render.sh"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"path": "bridge/browser-job.mjs"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"path": "scripts/check-browser-viewports.mjs"
|
|
211
|
+
}
|
|
197
212
|
],
|
|
198
213
|
"evidence": [
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
{
|
|
215
|
+
"path": "bridge/viewport.mjs",
|
|
216
|
+
"pattern": "preview_start"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"path": "bridge/viewport.mjs",
|
|
220
|
+
"pattern": "preview_capture"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"path": "bridge/viewport.mjs",
|
|
224
|
+
"pattern": "preview_inspect"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"path": "bridge/viewport.mjs",
|
|
228
|
+
"pattern": "preview_stop"
|
|
229
|
+
}
|
|
203
230
|
]
|
|
204
231
|
},
|
|
205
232
|
{
|
package/viewport.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{spawn as e}from"node:child_process";import t from"node:fs";import r from"node:fs/promises";import o from"node:os";import n from"node:path";import{randomUUID as i}from"node:crypto";import{previews as s,startPreview as a}from"./flow-preview.mjs";import{DESIGN_SOURCE_MAP_RELATIVE as l,MAX_DESIGN_SOURCE_MAP_BYTES as c}from"./design-source-contract.mjs";export const DEFAULT_VIEWPORTS=Object.freeze({desktop:Object.freeze({width:1440,height:900}),mobile:Object.freeze({width:390,height:844})});const u=5e3,h=(e,t)=>{const r=n.relative(e,t);return""===r||!r.startsWith("..")&&!n.isAbsolute(r)};export async function resolveContainedRoot(e,o="dist"){if(!e)throw new Error("A lane workspace is required.");if(n.isAbsolute(o))throw new Error("Preview root must be relative to the lane workspace.");const i=await r.realpath(n.resolve(e)),s=n.resolve(i,o||"dist");let a;try{a=await r.realpath(s)}catch{throw new Error(`Preview root does not exist: ${o||"dist"}. Build the app first, or choose a directory containing index.html.`)}if(!h(i,a))throw new Error("Preview root escapes the lane workspace.");if(!(await r.stat(a)).isDirectory())throw new Error("Preview root must be a directory.");if(!t.existsSync(n.join(a,"index.html")))throw new Error(`No index.html found in preview root: ${o||"dist"}.`);return a}export async function resolvePreviewSourceMap(e){if(!e)return null;let t,o;try{t=await r.realpath(e),o=await r.realpath(n.join(t,l));const i=await r.stat(o);if(!i.isFile()||i.size<=0||i.size>c)return null}catch{return null}return h(t,o)?o:null}export function normalizeRoute(e="/"){const t=String(e||"/").trim();if(!t.startsWith("/")||t.startsWith("//"))throw new Error('Preview path must start with one "/" and cannot be a URL.');const r=new URL(t,"http://127.0.0.1");if("http://127.0.0.1"!==r.origin)throw new Error("Preview path cannot change the preview origin.");return`${r.pathname}${r.search}${r.hash}`}export function safeSlug(e="viewport"){return String(e||"viewport").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,56)||"viewport"}export function isSignedOutRoomPreview(e,t){return!!new URL(normalizeRoute(e),"http://127.0.0.1").searchParams.get("r")&&/\bid\s*=\s*["']invitation-room-title["']/i.test(String(t||""))}export function previewArtifactIssue(e,t){const r=new URL(normalizeRoute(e),"http://127.0.0.1"),o=e=>{if("string"!=typeof e||!e.trim())return null;try{const t=new URL(e,"http://127.0.0.1");return`${"/"===t.pathname?"/":t.pathname.replace(/\/+$/,"")}${t.search}`}catch{return null}},n=o(r.href),i="/code"===r.pathname||r.pathname.startsWith("/code/")||r.searchParams.has("r");for(const e of[t?.beforeState,t?.afterState]){if(!e)return"The captured page did not report settled rendered state.";const t=o(e.url);if(t&&t!==n)return`Preview navigated from ${n} to ${t}.`;if(e.signedOutInvite)return"Preview resolved to the signed-out room invitation instead of the authenticated Code room.";if(e.visibleBoot)return"Preview was still showing the application loading shell.";if(e.visibleLoading)return"Preview was still showing an application loading state.";if(i&&e.prerenderGeo)return"Preview resolved to the public marketing shell instead of the authenticated Code surface.";if(!e.hasBody||e.textLength<2&&e.meaningfulVisualCount<1)return"Preview rendered an empty page."}return null}export function routeNeedsPreviewAuth(e){const t=new URL(normalizeRoute(e),"http://127.0.0.1");return!!t.searchParams.get("r")||["/settings","/contacts","/code/link"].includes(t.pathname)}export function previewAuthBootstrap({accessToken:e,supabaseUrl:t,nowSec:r=Math.floor(Date.now()/1e3),minTtlSec:o=0}={}){const n=function(e){try{const t=String(e||"").split(".")[1];return t?JSON.parse(Buffer.from(t,"base64url").toString("utf8")):null}catch{return null}}(e);let i;try{i=new URL(t)}catch{return null}if(!e||"https:"!==i.protocol||!n?.sub||!n?.exp||n.exp-r<=o)return null;const s=i.hostname.split(".")[0];return s?{storageKey:`sb-${s}-auth-token`,allowedReadOrigins:[i.origin],allowedSocketOrigins:[`wss://${i.host}`],session:{access_token:e,refresh_token:"",expires_in:Math.max(1,n.exp-r)+3600,expires_at:n.exp+3600,token_type:"bearer",user:{id:n.sub,aud:n.aud||"authenticated",role:n.role||"authenticated",email:n.email||null,phone:n.phone||"",app_metadata:n.app_metadata||{},user_metadata:n.user_metadata||{},identities:[],created_at:n.created_at||new Date(1e3*r).toISOString(),updated_at:new Date(1e3*r).toISOString()}}}:null}export function findBrowserExecutable({env:e=process.env,platform:r=process.platform,exists:o=t.existsSync}={}){return[e.TP_BROWSER_PATH,"darwin"===r&&"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","darwin"===r&&"/Applications/Chromium.app/Contents/MacOS/Chromium","win32"===r&&e.PROGRAMFILES&&n.join(e.PROGRAMFILES,"Google","Chrome","Application","chrome.exe"),"win32"===r&&e["PROGRAMFILES(X86)"]&&n.join(e["PROGRAMFILES(X86)"],"Google","Chrome","Application","chrome.exe"),"linux"===r&&"/usr/bin/google-chrome","linux"===r&&"/usr/bin/google-chrome-stable","linux"===r&&"/usr/bin/chromium","linux"===r&&"/usr/bin/chromium-browser"].filter(Boolean).find(e=>{try{return o(e)}catch{return!1}})||null}class d{constructor(e,t=12e3){this.child=e,this.timeoutMs=t,this.nextId=1,this.buffer=Buffer.alloc(0),this.pending=new Map,this.waiters=new Set,this.subscribers=new Set,e.stdio[4].on("data",e=>this.onData(e)),e.once("exit",(e,t)=>this.failAll(new Error(`Chrome exited (${t||e||"unknown"}).`))),e.once("error",e=>this.failAll(e))}onData(e){for(this.buffer=Buffer.concat([this.buffer,e]);;){const e=this.buffer.indexOf(0);if(e<0)break;const t=this.buffer.subarray(0,e).toString("utf8");if(this.buffer=this.buffer.subarray(e+1),!t)continue;let r;try{r=JSON.parse(t)}catch{continue}if(r.id){const e=this.pending.get(r.id);if(!e)continue;clearTimeout(e.timer),this.pending.delete(r.id),r.error?e.reject(new Error(r.error.message||"Chrome DevTools error")):e.resolve(r.result||{});continue}for(const e of[...this.waiters])e.method===r.method&&(e.sessionId&&e.sessionId!==r.sessionId||e.predicate&&!e.predicate(r.params||{})||(clearTimeout(e.timer),this.waiters.delete(e),e.resolve(r.params||{})));for(const e of[...this.subscribers])if(e.method===r.method&&(!e.sessionId||e.sessionId===r.sessionId))try{e.handler(r.params||{})}catch{}}}failAll(e){for(const t of this.pending.values())clearTimeout(t.timer),t.reject(e);this.pending.clear();for(const t of this.waiters)clearTimeout(t.timer),t.reject(e);this.waiters.clear(),this.subscribers.clear()}send(e,t={},r){const o=this.nextId++;return new Promise((n,i)=>{const s=setTimeout(()=>{this.pending.delete(o),i(new Error(`Chrome timed out running ${e}.`))},this.timeoutMs);this.pending.set(o,{resolve:n,reject:i,timer:s});const a={id:o,method:e,params:t,...r?{sessionId:r}:{}};this.child.stdio[3].write(`${JSON.stringify(a)}\0`)})}waitFor(e,t,r){return new Promise((o,n)=>{const i={method:e,sessionId:t,predicate:r,resolve:o,reject:n,timer:null};i.timer=setTimeout(()=>{this.waiters.delete(i),n(new Error(`Chrome timed out waiting for ${e}.`))},this.timeoutMs),this.waiters.add(i)})}subscribe(e,t,r){const o={method:e,sessionId:t,handler:r};return this.subscribers.add(o),()=>this.subscribers.delete(o)}}export class CdpBrowser{constructor({executable:t,spawnImpl:r=e,timeoutMs:o=12e3}={}){this.executable=t||null,this.spawnImpl=r,this.timeoutMs=o,this.child=null,this.pipe=null,this.profileDir=null,this.starting=null,this.stderr=""}async ensureStarted(){if(!this.child||!this.pipe)return this.starting||(this.starting=this.start().finally(()=>{this.starting=null})),this.starting}async start(){const e=this.executable||findBrowserExecutable();if(!e)throw new Error("No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.");this.profileDir=await r.mkdtemp(n.join(o.tmpdir(),"thinkpool-viewport-"));const t=["--headless=new","--remote-debugging-pipe","--no-first-run","--no-default-browser-check","--disable-background-networking","--disable-component-update","--disable-sync","--disable-extensions","--disable-default-apps","--metrics-recording-only","--mute-audio","--hide-scrollbars",`--user-data-dir=${this.profileDir}`,"about:blank"],i=this.spawnImpl(e,t,{stdio:["ignore","ignore","pipe","pipe","pipe"]});this.child=i,i.stderr?.on("data",e=>{this.stderr=(this.stderr+e.toString("utf8")).slice(-4e3)}),this.pipe=new d(i,this.timeoutMs);try{await this.pipe.send("Browser.getVersion")}catch(e){await this.close();const t=this.stderr.trim().split("\n").slice(-2).join(" ");throw new Error(`Could not start Chrome for viewport capture: ${t||e.message}`)}}async withPage({url:e,viewport:t,waitMs:r=300,auth:o=null},n){await this.ensureStarted();const{targetId:i}=await this.pipe.send("Target.createTarget",{url:"about:blank"}),{sessionId:s}=await this.pipe.send("Target.attachToTarget",{targetId:i,flatten:!0});let a=null,l=null,c=null;const h=[],d=[],p=[];try{if(await this.pipe.send("Page.enable",{},s),await this.pipe.send("Runtime.enable",{},s),await this.pipe.send("Network.enable",{},s).catch(()=>{}),o?.storageKey&&o?.session){const e=`(() => { try { localStorage.setItem(${JSON.stringify(o.storageKey)}, ${JSON.stringify(JSON.stringify(o.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`;await this.pipe.send("Page.addScriptToEvaluateOnNewDocument",{source:e},s)}const i=new URL(e).origin,w=new Set(o?.allowedReadOrigins||[]),m=new Set(o?.allowedSocketOrigins||[]);l=this.pipe.subscribe("Network.responseReceived",s,e=>{try{const t=new URL(e.response?.url||"");w.has(t.origin)&&h.length<20&&h.push(`${e.response?.status||0}:${t.pathname}`),t.origin===i&&d.length<20&&d.push(`${e.response?.status||0}:${t.pathname}`)}catch{}}),c=this.pipe.subscribe("Runtime.exceptionThrown",s,e=>{if(p.length>=5)return;const t=e.exceptionDetails?.exception?.description||e.exceptionDetails?.text||"runtime error";p.push(String(t).split("\n")[0].slice(0,180))}),a=this.pipe.subscribe("Fetch.requestPaused",s,e=>{let t=!1;try{const r=new URL(e.request?.url||""),o=String(e.request?.method||"GET").toUpperCase();t=r.origin===i||w.has(r.origin)&&["GET","HEAD","OPTIONS"].includes(o)||m.has(r.origin)&&"GET"===o}catch{t=!1}const r=t?"Fetch.continueRequest":"Fetch.failRequest",o=t?{requestId:e.requestId}:{requestId:e.requestId,errorReason:"BlockedByClient"};this.pipe.send(r,o,s).catch(()=>{})}),await this.pipe.send("Fetch.enable",{patterns:[{urlPattern:"*",requestStage:"Request"}]},s),await this.pipe.send("Network.setBypassServiceWorker",{bypass:!0},s).catch(()=>{}),await this.pipe.send("Emulation.setDeviceMetricsOverride",{width:t.width,height:t.height,deviceScaleFactor:1,mobile:!1,screenWidth:t.width,screenHeight:t.height},s);const f=this.pipe.waitFor("Page.loadEventFired",s),g=await this.pipe.send("Page.navigate",{url:e},s);if(g.errorText)throw new Error(`Preview navigation failed: ${g.errorText}`);if(await f,await this.pipe.send("Runtime.evaluate",{expression:"document.fonts && document.fonts.ready",awaitPromise:!0,returnByValue:!0},s).catch(()=>{}),o){const e=Date.now()+15e3;let t=!1,r=0;for(;Date.now()<e;){const e=await this.pipe.send("Runtime.evaluate",{expression:"(() => {\n if (document.querySelector('#invitation-room-title')) return true;\n if (document.querySelector('#boot, .boot-loader')) return false;\n const room = document.querySelector('[data-tp-preview-room-status]');\n if (room?.dataset.tpPreviewRoomStatus === 'pending') return false;\n const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test((node.textContent || '').trim()));\n return !loading && (document.body?.innerText || '').trim().length > 1;\n })()",returnByValue:!0},s);if(!0===e.result?.value){if(r||(r=Date.now()),Date.now()-r>=800){t=!0;break}}else r=0;await new Promise(e=>setTimeout(e,100))}if(!t){const e=await this.pipe.send("Runtime.evaluate",{expression:`(async () => {\n let locks = { held: [], pending: [] };\n try { locks = await navigator.locks.query(); } catch {}\n return {\n sessionPresent: !!localStorage.getItem(${JSON.stringify(o.storageKey||"")}),\n inlineBoot: !!document.querySelector('#boot, .boot-loader'),\n loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),\n connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),\n invitation: !!document.querySelector('#invitation-room-title'),\n roomStatus: document.querySelector('[data-tp-preview-room-status]')?.dataset.tpPreviewRoomStatus || null,\n heldLocks: locks.held?.length || 0,\n pendingLocks: locks.pending?.length || 0,\n };\n })()`,awaitPromise:!0,returnByValue:!0},s).catch(()=>({result:{value:{}}})),t=e.result?.value||{};throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${t.sessionPresent?"present":"missing"}, inlineBoot=${t.inlineBoot?"yes":"no"}, loadingSession=${t.loadingSession?"yes":"no"}, connecting=${t.connecting?"yes":"no"}, roomStatus=${t.roomStatus||"none"}, authLocks=${t.heldLocks||0}/${t.pendingLocks||0}, invitation=${t.invitation?"yes":"no"}, reads=${h.join(",")||"none"}, local=${d.join(",")||"none"}, runtime=${p.join(" | ")||"none"}); no screenshot was created.`)}}const b=o?Math.min(300,Math.max(0,Number(r)||0)):Math.min(u,Math.max(0,Number(r)||0));return b&&await new Promise(e=>setTimeout(e,b)),await n({pipe:this.pipe,sessionId:s})}finally{a?.(),l?.(),c?.(),await this.pipe.send("Target.closeTarget",{targetId:i}).catch(()=>{})}}async renderedState(e,t){const r=await e.send("Runtime.evaluate",{expression:"(() => {\n const visible = (node) => {\n if (!node) return false;\n const style = getComputedStyle(node);\n const rect = node.getBoundingClientRect();\n return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0;\n };\n const boot = document.querySelector('#boot');\n const meaningful = [...document.querySelectorAll('main,article,section,img,video,canvas,svg,[role=\"dialog\"],[role=\"main\"],[role=\"img\"]')]\n .filter((node) => !node.closest('#boot') && visible(node));\n return {\n title: document.title || '', url: location.href,\n hasBody: !!document.body,\n textLength: (document.body?.innerText || '').trim().length,\n meaningfulVisualCount: meaningful.length,\n visibleBoot: visible(boot),\n visibleLoading: [...document.querySelectorAll('span')].some((node) => {\n const text = (node.textContent || '').trim();\n return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test(text);\n }),\n signedOutInvite: !!document.querySelector('#invitation-room-title'),\n prerenderGeo: !!document.querySelector('[data-prerender=\"geo\"]'),\n };\n })()",returnByValue:!0},t);if(r.exceptionDetails)throw new Error("Could not inspect the rendered preview state.");return r.result?.value||null}async freezeCurrentPage(e,t){const r=await e.send("Runtime.evaluate",{expression:"(async () => {\n const clone = document.documentElement.cloneNode(true);\n clone.querySelectorAll('script,link[rel=\"modulepreload\"],link[rel=\"preload\"]').forEach((node) => node.remove());\n clone.querySelectorAll('*').forEach((node) => {\n for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);\n if (node instanceof HTMLInputElement) node.removeAttribute('value');\n if (node instanceof HTMLTextAreaElement) node.textContent = '';\n });\n const css = [];\n for (const sheet of [...document.styleSheets]) {\n try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}\n }\n clone.querySelectorAll('style,link[rel=\"stylesheet\"]').forEach((node) => node.remove());\n const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);\n const style = document.createElement('style');\n style.setAttribute('data-thinkpool-snapshot', '');\n style.textContent = css.join('\\n');\n head.appendChild(style);\n\n const assetUrls = new Set();\n const remember = (raw) => {\n if (!raw || /^(data:|blob:|#)/i.test(raw)) return;\n try {\n const absolute = new URL(raw, location.href);\n if (absolute.origin === location.origin) assetUrls.add(absolute.href);\n } catch {}\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n remember(node.getAttribute('src'));\n remember(node.getAttribute('poster'));\n });\n for (const match of style.textContent.matchAll(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);\n\n const replacements = new Map();\n let inlinedBytes = 0;\n for (const absolute of assetUrls) {\n try {\n const response = await fetch(absolute);\n const buffer = await response.arrayBuffer();\n if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));\n replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));\n inlinedBytes += buffer.byteLength;\n } catch {}\n }\n const replaceAsset = (raw) => {\n try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));\n node.removeAttribute('srcset');\n });\n style.textContent = style.textContent.replace(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url(\"' + replaceAsset(raw) + '\")');\n return '<!doctype html>\\n' + clone.outerHTML;\n })()",awaitPromise:!0,returnByValue:!0},t);if(r.exceptionDetails||"string"!=typeof r.result?.value){const e=r.exceptionDetails?.exception?.description||r.exceptionDetails?.text||r.result?.description;throw new Error("Could not freeze the rendered preview for Design"+(e?`: ${e}`:"."))}const o=r.result.value;if(!o.trim()||Buffer.byteLength(o)>19e5)throw new Error("The rendered preview is too large to make editable.");return o}async capture({url:e,viewport:t,fullPage:r=!0,waitMs:o=300,includeSnapshot:n=!1,auth:i=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:i},async({pipe:e,sessionId:o})=>{const i=await this.renderedState(e,o),s=await e.send("Page.getLayoutMetrics",{},o),a=Math.ceil(s.cssContentSize?.height||t.height),l=r?Math.min(2e4,Math.max(t.height,a)):t.height,c=await e.send("Page.captureScreenshot",{format:"png",fromSurface:!0,captureBeyondViewport:!0,clip:{x:0,y:0,width:t.width,height:l,scale:1}},o),u=await this.renderedState(e,o),h=n?await this.freezeCurrentPage(e,o):null;return{png:Buffer.from(c.data,"base64"),width:t.width,height:l,contentHeight:a,capped:a>2e4,beforeState:i,afterState:u,snapshot:h}})}async snapshot({url:e,viewport:t=DEFAULT_VIEWPORTS.desktop,waitMs:r=300,auth:o=null}){return this.withPage({url:e,viewport:t,waitMs:r,auth:o},({pipe:e,sessionId:t})=>this.freezeCurrentPage(e,t))}async inspect({url:e,viewport:t=DEFAULT_VIEWPORTS.mobile,selector:r,waitMs:o=300,auth:n=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:n},async({pipe:e,sessionId:t})=>{const o=`(() => {\n const selector = ${JSON.stringify(r||null)};\n const el = selector ? document.querySelector(selector) : null;\n const r = el && el.getBoundingClientRect();\n return {\n title: document.title, url: location.href,\n viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },\n document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },\n selector: selector ? { query: selector, found: !!el, tag: el?.tagName || null, text: el?.innerText?.slice(0, 2000) || '', rect: r ? { x:r.x, y:r.y, width:r.width, height:r.height } : null } : null,\n text: document.body?.innerText?.slice(0, 12000) || ''\n };\n })()`,n=await e.send("Runtime.evaluate",{expression:o,returnByValue:!0},t);if(n.exceptionDetails)throw new Error("DOM inspection failed in the preview page.");return n.result?.value||{}})}async close(){const e=this.child,t=this.pipe;if(this.child=null,this.pipe=null,t&&await t.send("Browser.close").catch(()=>{}),e&&null==e.exitCode)try{e.kill("SIGTERM")}catch{}this.profileDir&&await r.rm(this.profileDir,{recursive:!0,force:!0}).catch(()=>{}),this.profileDir=null}}export const sharedViewportBrowser=new CdpBrowser;export class ViewportManager{constructor({workspaceRoot:e,ownerId:t,roomCode:r,outbox:s,browser:l=sharedViewportBrowser,startPreviewImpl:c=a,designContext:u=null,authContext:h=null}={}){this.workspaceRoot=n.resolve(e||process.cwd()),this.ownerId=t||i(),this.roomCode="string"==typeof r&&r.trim()?r.trim():null,this.outbox=s||n.join(o.tmpdir(),"thinkpool-viewport-captures",this.ownerId),this.browser=l,this.startPreviewImpl=c,this.designContext="function"==typeof u?u:()=>null,this.authContext="function"==typeof h?h:()=>null,this.previewId=`viewport:${this.ownerId}`,this.preview=null,this.root=null}setWorkspaceRoot(e){if(!e)throw new Error("A lane workspace is required.");return this.workspaceRoot=n.resolve(e),this.workspaceRoot}async start({root:e="dist"}={}){const t=await resolveContainedRoot(this.workspaceRoot,e);await this.stop();const r=await this.startPreviewImpl({dir:t,host:"127.0.0.1",port:0,id:this.previewId}),o=new URL(r.url);if("127.0.0.1"!==o.hostname&&"localhost"!==o.hostname)throw await(r.stop?.()),new Error("Preview server did not bind to loopback.");return this.preview=r,this.root=t,{url:r.url,root:t}}requirePreview(){if(!this.preview?.url)throw new Error("No preview is running. Call preview_start after building the app.");return this.preview}defaultRoute(){return this.roomCode?`/?r=${encodeURIComponent(this.roomCode)}`:"/"}resolveRoute(e){return normalizeRoute(e??this.defaultRoute())}pageUrl(e){const t=this.requirePreview();return new URL(normalizeRoute(e),t.url).href}async previewAuth(e){if(!routeNeedsPreviewAuth(e))return null;let t=this.authContext()||{};if(!t.accessToken)return null;const r=Date.now()+15e3;for(;;){const e=previewAuthBootstrap({...t,minTtlSec:30});if(e)return e;if(Date.now()>=r)throw new Error("The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.");await new Promise(e=>setTimeout(e,250)),t=this.authContext()||{}}}async capture({route:e,viewports:t="both",title:o="Viewport capture",fullPage:i=!0,waitMs:s=300,card:a=!1}={}){const l=this.resolveRoute(e),c=this.pageUrl(l),u="both"===t?["desktop","mobile"]:[t];if(u.some(e=>!DEFAULT_VIEWPORTS[e]))throw new Error('viewports must be "both", "desktop", or "mobile".');const h=`${safeSlug(o)}-${Date.now().toString(36)}`,d=await r.realpath(this.workspaceRoot),p=n.relative(d,this.root)||".",w=JSON.stringify([p,l]),m=this.designContext()||null,f=m?.captureKey===w?{designRequestId:m.requestId,parentRevision:m.parentRevision}:{},g=!0===a||!!f.designRequestId,b={};for(const e of u)b[e]=await this.browser.capture({url:c,viewport:DEFAULT_VIEWPORTS[e],fullPage:i,waitMs:s,includeSnapshot:g&&e===(u.includes("desktop")?"desktop":u[0]),auth:await this.previewAuth(l)});const v=g&&!(!b.desktop||!b.mobile);if(v||routeNeedsPreviewAuth(l))for(const[e,t]of Object.entries(b)){const r=previewArtifactIssue(l,t);if(r)throw new Error(`${e} preview rejected: ${r} No screenshot or mockup card was created.`)}const y=b.desktop?.snapshot||b.mobile?.snapshot||null;if(v&&("string"!=typeof y||!y.trim()))throw new Error("The deliverable could not freeze its rendered source. No mockup card was created.");await r.mkdir(this.outbox,{recursive:!0});for(const[e,t]of Object.entries(b))t.file=n.join(this.outbox,`${h}--${e}.png`),await r.writeFile(t.file,t.png);const x=v?n.join(this.outbox,`${h}--source.html`):null;x&&await r.writeFile(x,y);let S=null;if(v){const e=await resolvePreviewSourceMap(this.root),t={slug:h,title:String(o||"Viewport capture").slice(0,120),desktop:b.desktop.file,mobile:b.mobile.file,ts:Date.now(),sourceKind:"preview",deliveryIntent:"card",readinessValidated:!0,snapshot:x,previewRoot:p,route:l,captureKey:w,...e?{sourceMap:e}:{},...f};S=n.join(this.outbox,`${h}.json`);const i=`${S}.tmp`;await r.writeFile(i,JSON.stringify(t)),await r.rename(i,S)}return{url:c,route:l,slug:h,manifestPath:S,captures:b,snapshotPath:x,cardReady:v}}async inspect({route:e,viewport:t="mobile",selector:r,waitMs:o=300}={}){if(!DEFAULT_VIEWPORTS[t])throw new Error('viewport must be "desktop" or "mobile".');const n=this.resolveRoute(e);return this.browser.inspect({url:this.pageUrl(n),viewport:DEFAULT_VIEWPORTS[t],selector:r,waitMs:o,auth:await this.previewAuth(n)})}async stop(){const e=this.preview||s.get(this.previewId);this.preview=null,this.root=null,e?.stop&&await e.stop()}}const p=e=>({content:[{type:"text",text:e}]}),w=e=>p(`Viewport error: ${e?.message||e}`);export function createViewportTools({tool:e,z:t,manager:r}){return[e("preview_start","Start a bridge-owned, read-only localhost preview for built files inside this lane workspace. Run the project build first. The default root is dist; pass another relative directory only when its index.html is the intended preview.",{root:t.string().max(240).optional().describe("relative directory inside this lane workspace; default: dist")},async e=>{try{const t=await r.start({root:e?.root||"dist"});return p(`Preview ready from ${n.relative(r.workspaceRoot,t.root)||"."}. Use preview_capture to see desktop/mobile, or preview_inspect for DOM text and geometry.`)}catch(e){return w(e)}}),e("preview_capture","Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Omit path to preview the current authenticated room; pass / explicitly for the public landing page. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewports:t.enum(["both","desktop","mobile"]).optional(),title:t.string().max(120).optional(),fullPage:t.boolean().optional(),card:t.boolean().optional().describe("default false; true only for an intentional user-facing mockup/Design deliverable"),waitMs:t.number().int().min(0).max(u).optional().describe("settle time after load, max 5000ms")},async e=>{try{const t=await r.capture({route:e?.path,viewports:e?.viewports||"both",title:e?.title||"Viewport capture",fullPage:!1!==e?.fullPage,waitMs:e?.waitMs??300,card:!0===e?.card}),o=t.cardReady?"The complete room preview card is queued after your final response.":"This is verification evidence only; no room card was created.",n=[{type:"text",text:`Captured ${Object.keys(t.captures).join(" + ")} for ${t.route}. ${o}`}];for(const[e,r]of Object.entries(t.captures))n.push({type:"text",text:`${e}: ${r.width}×${r.height}${r.capped?" (height capped)":""}`}),n.push({type:"image",data:r.png.toString("base64"),mimeType:"image/png"});return{content:n}}catch(e){return w(e)}}),e("preview_inspect","Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Omit path for the current authenticated room; pass / explicitly for the public landing page. Returns page text, document dimensions, and optional selector text/geometry without changing the page.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewport:t.enum(["desktop","mobile"]).optional(),selector:t.string().max(500).optional(),waitMs:t.number().int().min(0).max(u).optional()},async e=>{try{const t=await r.inspect({route:e?.path,viewport:e?.viewport||"mobile",selector:e?.selector,waitMs:e?.waitMs??300});return p(JSON.stringify(t,null,2))}catch(e){return w(e)}}),e("preview_stop","Stop this lane's bridge-owned preview server and release its port.",{},async()=>{try{return await r.stop(),p("Preview stopped.")}catch(e){return w(e)}})]}
|
|
1
|
+
import{spawn as e}from"node:child_process";import t from"node:fs";import r from"node:fs/promises";import o from"node:os";import i from"node:path";import{randomUUID as n}from"node:crypto";import{acquireBrowserJob as s}from"./browser-job.mjs";import{previews as a,startPreview as l}from"./flow-preview.mjs";import{DESIGN_SOURCE_MAP_RELATIVE as c,MAX_DESIGN_SOURCE_MAP_BYTES as u}from"./design-source-contract.mjs";export const DEFAULT_VIEWPORTS=Object.freeze({desktop:Object.freeze({width:1440,height:900}),mobile:Object.freeze({width:390,height:844,mobile:!0,hasTouch:!0})});const d=5e3;export const BROWSER_IDLE_TIMEOUT_MS=6e4;const h=(e,t)=>{const r=i.relative(e,t);return""===r||!r.startsWith("..")&&!i.isAbsolute(r)};export async function resolveContainedRoot(e,o="dist"){if(!e)throw new Error("A lane workspace is required.");if(i.isAbsolute(o))throw new Error("Preview root must be relative to the lane workspace.");const n=await r.realpath(i.resolve(e)),s=i.resolve(n,o||"dist");let a;try{a=await r.realpath(s)}catch{throw new Error(`Preview root does not exist: ${o||"dist"}. Build the app first, or choose a directory containing index.html.`)}if(!h(n,a))throw new Error("Preview root escapes the lane workspace.");if(!(await r.stat(a)).isDirectory())throw new Error("Preview root must be a directory.");if(!t.existsSync(i.join(a,"index.html")))throw new Error(`No index.html found in preview root: ${o||"dist"}.`);return a}export async function resolvePreviewSourceMap(e){if(!e)return null;let t,o;try{t=await r.realpath(e),o=await r.realpath(i.join(t,c));const n=await r.stat(o);if(!n.isFile()||n.size<=0||n.size>u)return null}catch{return null}return h(t,o)?o:null}export function normalizeRoute(e="/"){const t=String(e||"/").trim();if(!t.startsWith("/")||t.startsWith("//"))throw new Error('Preview path must start with one "/" and cannot be a URL.');const r=new URL(t,"http://127.0.0.1");if("http://127.0.0.1"!==r.origin)throw new Error("Preview path cannot change the preview origin.");return`${r.pathname}${r.search}${r.hash}`}export function safeSlug(e="viewport"){return String(e||"viewport").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,56)||"viewport"}export function isSignedOutRoomPreview(e,t){return!!new URL(normalizeRoute(e),"http://127.0.0.1").searchParams.get("r")&&/\bid\s*=\s*["']invitation-room-title["']/i.test(String(t||""))}export function previewArtifactIssue(e,t){const r=new URL(normalizeRoute(e),"http://127.0.0.1"),o=e=>{if("string"!=typeof e||!e.trim())return null;try{const t=new URL(e,"http://127.0.0.1");return`${"/"===t.pathname?"/":t.pathname.replace(/\/+$/,"")}${t.search}`}catch{return null}},i=o(r.href),n="/code"===r.pathname||r.pathname.startsWith("/code/")||r.searchParams.has("r");for(const e of[t?.beforeState,t?.afterState]){if(!e)return"The captured page did not report settled rendered state.";const t=o(e.url);if(t&&t!==i)return`Preview navigated from ${i} to ${t}.`;if(e.signedOutInvite)return"Preview resolved to the signed-out room invitation instead of the authenticated Code room.";if(e.visibleBoot)return"Preview was still showing the application loading shell.";if(e.visibleLoading)return"Preview was still showing an application loading state.";if(n&&e.prerenderGeo)return"Preview resolved to the public marketing shell instead of the authenticated Code surface.";if(!e.hasBody||e.textLength<2&&e.meaningfulVisualCount<1)return"Preview rendered an empty page."}return null}export function routeNeedsPreviewAuth(e){const t=new URL(normalizeRoute(e),"http://127.0.0.1");return!!t.searchParams.get("r")||["/settings","/contacts","/code/link"].includes(t.pathname)}export function previewAuthBootstrap({accessToken:e,supabaseUrl:t,nowSec:r=Math.floor(Date.now()/1e3),minTtlSec:o=0}={}){const i=function(e){try{const t=String(e||"").split(".")[1];return t?JSON.parse(Buffer.from(t,"base64url").toString("utf8")):null}catch{return null}}(e);let n;try{n=new URL(t)}catch{return null}if(!e||"https:"!==n.protocol||!i?.sub||!i?.exp||i.exp-r<=o)return null;const s=n.hostname.split(".")[0];return s?{storageKey:`sb-${s}-auth-token`,allowedReadOrigins:[n.origin],allowedSocketOrigins:[`wss://${n.host}`],session:{access_token:e,refresh_token:"",expires_in:Math.max(1,i.exp-r)+3600,expires_at:i.exp+3600,token_type:"bearer",user:{id:i.sub,aud:i.aud||"authenticated",role:i.role||"authenticated",email:i.email||null,phone:i.phone||"",app_metadata:i.app_metadata||{},user_metadata:i.user_metadata||{},identities:[],created_at:i.created_at||new Date(1e3*r).toISOString(),updated_at:new Date(1e3*r).toISOString()}}}:null}export function findBrowserExecutable({env:e=process.env,platform:r=process.platform,exists:o=t.existsSync}={}){return[e.TP_BROWSER_PATH,"darwin"===r&&"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","darwin"===r&&"/Applications/Chromium.app/Contents/MacOS/Chromium","win32"===r&&e.PROGRAMFILES&&i.join(e.PROGRAMFILES,"Google","Chrome","Application","chrome.exe"),"win32"===r&&e["PROGRAMFILES(X86)"]&&i.join(e["PROGRAMFILES(X86)"],"Google","Chrome","Application","chrome.exe"),"linux"===r&&"/usr/bin/google-chrome","linux"===r&&"/usr/bin/google-chrome-stable","linux"===r&&"/usr/bin/chromium","linux"===r&&"/usr/bin/chromium-browser"].filter(Boolean).find(e=>{try{return o(e)}catch{return!1}})||null}class p{constructor(e,t=12e3){this.child=e,this.timeoutMs=t,this.nextId=1,this.buffer=Buffer.alloc(0),this.pending=new Map,this.waiters=new Set,this.subscribers=new Set,e.stdio[4].on("data",e=>this.onData(e)),e.once("exit",(e,t)=>this.failAll(new Error(`Chrome exited (${t||e||"unknown"}).`))),e.once("error",e=>this.failAll(e))}onData(e){for(this.buffer=Buffer.concat([this.buffer,e]);;){const e=this.buffer.indexOf(0);if(e<0)break;const t=this.buffer.subarray(0,e).toString("utf8");if(this.buffer=this.buffer.subarray(e+1),!t)continue;let r;try{r=JSON.parse(t)}catch{continue}if(r.id){const e=this.pending.get(r.id);if(!e)continue;clearTimeout(e.timer),this.pending.delete(r.id),r.error?e.reject(new Error(r.error.message||"Chrome DevTools error")):e.resolve(r.result||{});continue}for(const e of[...this.waiters])e.method===r.method&&(e.sessionId&&e.sessionId!==r.sessionId||e.predicate&&!e.predicate(r.params||{})||(clearTimeout(e.timer),this.waiters.delete(e),e.resolve(r.params||{})));for(const e of[...this.subscribers])if(e.method===r.method&&(!e.sessionId||e.sessionId===r.sessionId))try{e.handler(r.params||{})}catch{}}}failAll(e){for(const t of this.pending.values())clearTimeout(t.timer),t.reject(e);this.pending.clear();for(const t of this.waiters)clearTimeout(t.timer),t.reject(e);this.waiters.clear(),this.subscribers.clear()}send(e,t={},r){const o=this.nextId++;return new Promise((i,n)=>{const s=setTimeout(()=>{this.pending.delete(o),n(new Error(`Chrome timed out running ${e}.`))},this.timeoutMs);this.pending.set(o,{resolve:i,reject:n,timer:s});const a={id:o,method:e,params:t,...r?{sessionId:r}:{}};this.child.stdio[3].write(`${JSON.stringify(a)}\0`)})}waitFor(e,t,r){return new Promise((o,i)=>{const n={method:e,sessionId:t,predicate:r,resolve:o,reject:i,timer:null};n.timer=setTimeout(()=>{this.waiters.delete(n),i(new Error(`Chrome timed out waiting for ${e}.`))},this.timeoutMs),this.waiters.add(n)})}subscribe(e,t,r){const o={method:e,sessionId:t,handler:r};return this.subscribers.add(o),()=>this.subscribers.delete(o)}cancelWaiters(e){for(const t of[...this.waiters])t.sessionId===e&&(clearTimeout(t.timer),this.waiters.delete(t),t.reject(new Error("Preview page closed.")))}}export class CdpBrowser{constructor({executable:t,spawnImpl:r=e,timeoutMs:o=12e3,idleTimeoutMs:i=6e4,acquireJob:n=s}={}){this.executable=t||null,this.spawnImpl=r,this.timeoutMs=o,this.child=null,this.pipe=null,this.profileDir=null,this.starting=null,this.closing=null,this.idleTimer=null,this.idleTimeoutMs=i,this.acquireJob=n,this.jobs=0,this.version=null,this.stderr=""}async ensureStarted(){return this.closing&&await this.closing,this.starting?this.starting:(!this.child||null==this.child.exitCode&&null==this.child.signalCode||await this.close(),this.child&&this.pipe?void 0:(this.starting=this.start().finally(()=>{this.starting=null}),this.starting))}async start(){const e=this.executable||findBrowserExecutable();if(!e)throw new Error("No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.");this.profileDir=await r.mkdtemp(i.join(o.tmpdir(),"thinkpool-viewport-"));const t=["--headless=new","--remote-debugging-pipe","--no-first-run","--no-default-browser-check","--disable-background-networking","--disable-component-update","--disable-sync","--disable-extensions","--disable-default-apps","--metrics-recording-only","--mute-audio","--hide-scrollbars",`--user-data-dir=${this.profileDir}`,"about:blank"],n=this.spawnImpl(e,t,{stdio:["ignore","ignore","pipe","pipe","pipe"]});this.child=n,n.stderr?.on("data",e=>{this.stderr=(this.stderr+e.toString("utf8")).slice(-4e3)}),this.pipe=new p(n,this.timeoutMs);try{this.version=await this.pipe.send("Browser.getVersion")}catch(e){await this.close();const t=this.stderr.trim().split("\n").slice(-2).join(" ");throw new Error(`Could not start Chrome for viewport capture: ${t||e.message}`)}}async withPage({url:e,viewport:t,waitMs:r=300,auth:o=null},i){this.jobs++,clearTimeout(this.idleTimer),this.idleTimer=null;let n=null,s=null,a=null,l=null,c=null,u=null,h=null;const p=[],w=[],m=[];try{if(n=await this.acquireJob(),await this.ensureStarted(),s=this.pipe,a=(await s.send("Target.createBrowserContext",{disposeOnDetach:!0})).browserContextId,!a)throw new Error("Chrome did not create an isolated preview context.");const{targetId:f}=await s.send("Target.createTarget",{url:"about:blank",browserContextId:a}),g=await s.send("Target.attachToTarget",{targetId:f,flatten:!0});if(l=g.sessionId,await s.send("Page.enable",{},l),await s.send("Runtime.enable",{},l),await s.send("Network.enable",{},l).catch(()=>{}),o?.storageKey&&o?.session){const e=`(() => { try { localStorage.setItem(${JSON.stringify(o.storageKey)}, ${JSON.stringify(JSON.stringify(o.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`;await s.send("Page.addScriptToEvaluateOnNewDocument",{source:e},l)}const b=new URL(e).origin,v=new Set(o?.allowedReadOrigins||[]),y=new Set(o?.allowedSocketOrigins||[]);if(u=s.subscribe("Network.responseReceived",l,e=>{try{const t=new URL(e.response?.url||"");v.has(t.origin)&&p.length<20&&p.push(`${e.response?.status||0}:${t.pathname}`),t.origin===b&&w.length<20&&w.push(`${e.response?.status||0}:${t.pathname}`)}catch{}}),h=s.subscribe("Runtime.exceptionThrown",l,e=>{if(m.length>=5)return;const t=e.exceptionDetails?.exception?.description||e.exceptionDetails?.text||"runtime error";m.push(String(t).split("\n")[0].slice(0,180))}),c=s.subscribe("Fetch.requestPaused",l,e=>{let t=!1;try{const r=new URL(e.request?.url||""),o=String(e.request?.method||"GET").toUpperCase();t=r.origin===b||v.has(r.origin)&&["GET","HEAD","OPTIONS"].includes(o)||y.has(r.origin)&&"GET"===o}catch{t=!1}const r=t?"Fetch.continueRequest":"Fetch.failRequest",o=t?{requestId:e.requestId}:{requestId:e.requestId,errorReason:"BlockedByClient"};s.send(r,o,l).catch(()=>{})}),await s.send("Fetch.enable",{patterns:[{urlPattern:"*",requestStage:"Request"}]},l),await s.send("Network.setBypassServiceWorker",{bypass:!0},l).catch(()=>{}),await s.send("Emulation.setDeviceMetricsOverride",{width:t.width,height:t.height,deviceScaleFactor:1,mobile:!0===t.mobile,screenWidth:t.width,screenHeight:t.height},l),await s.send("Emulation.setTouchEmulationEnabled",{enabled:!0===t.hasTouch,maxTouchPoints:t.hasTouch?5:1},l),t.mobile){const e=this.version?.product?.match(/(?:Chrome|Chromium)\/([\d.]+)/)?.[1];if(!e)throw new Error("Chrome did not report its version for mobile emulation.");await s.send("Emulation.setUserAgentOverride",{userAgent:`Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${e} Mobile Safari/537.36`,platform:"Android",userAgentMetadata:{platform:"Android",platformVersion:"13.0.0",architecture:"",model:"",mobile:!0}},l)}const x=s.waitFor("Page.loadEventFired",l);x.catch(()=>{});const S=await s.send("Page.navigate",{url:e},l);if(S.errorText)throw new Error(`Preview navigation failed: ${S.errorText}`);if(await x,await s.send("Runtime.evaluate",{expression:"document.fonts && document.fonts.ready",awaitPromise:!0,returnByValue:!0},l).catch(()=>{}),o){const e=Date.now()+15e3;let t=!1,r=0;for(;Date.now()<e;){const e=await s.send("Runtime.evaluate",{expression:"(() => {\n if (document.querySelector('#invitation-room-title')) return true;\n if (document.querySelector('#boot, .boot-loader')) return false;\n const room = document.querySelector('[data-tp-preview-room-status]');\n if (room?.dataset.tpPreviewRoomStatus === 'pending') return false;\n const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test((node.textContent || '').trim()));\n return !loading && (document.body?.innerText || '').trim().length > 1;\n })()",returnByValue:!0},l);if(!0===e.result?.value){if(r||(r=Date.now()),Date.now()-r>=800){t=!0;break}}else r=0;await new Promise(e=>setTimeout(e,100))}if(!t){const e=await s.send("Runtime.evaluate",{expression:`(async () => {\n let locks = { held: [], pending: [] };\n try { locks = await navigator.locks.query(); } catch {}\n return {\n sessionPresent: !!localStorage.getItem(${JSON.stringify(o.storageKey||"")}),\n inlineBoot: !!document.querySelector('#boot, .boot-loader'),\n loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),\n connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),\n invitation: !!document.querySelector('#invitation-room-title'),\n roomStatus: document.querySelector('[data-tp-preview-room-status]')?.dataset.tpPreviewRoomStatus || null,\n heldLocks: locks.held?.length || 0,\n pendingLocks: locks.pending?.length || 0,\n };\n })()`,awaitPromise:!0,returnByValue:!0},l).catch(()=>({result:{value:{}}})),t=e.result?.value||{};throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${t.sessionPresent?"present":"missing"}, inlineBoot=${t.inlineBoot?"yes":"no"}, loadingSession=${t.loadingSession?"yes":"no"}, connecting=${t.connecting?"yes":"no"}, roomStatus=${t.roomStatus||"none"}, authLocks=${t.heldLocks||0}/${t.pendingLocks||0}, invitation=${t.invitation?"yes":"no"}, reads=${p.join(",")||"none"}, local=${w.join(",")||"none"}, runtime=${m.join(" | ")||"none"}); no screenshot was created.`)}}const R=o?Math.min(300,Math.max(0,Number(r)||0)):Math.min(d,Math.max(0,Number(r)||0));return R&&await new Promise(e=>setTimeout(e,R)),await i({pipe:s,sessionId:l})}finally{c?.(),u?.(),h?.(),l&&s.cancelWaiters(l);try{if(a)try{await s.send("Target.disposeBrowserContext",{browserContextId:a})}catch{await this.close()}}finally{try{await(n?.())}finally{this.jobs--,this.scheduleIdleClose()}}}}scheduleIdleClose(){clearTimeout(this.idleTimer),this.idleTimer=null,!this.jobs&&this.child&&(this.idleTimer=setTimeout(()=>{this.idleTimer=null,this.jobs||this.close().catch(()=>{})},this.idleTimeoutMs),this.idleTimer.unref?.())}async renderedState(e,t){const r=await e.send("Runtime.evaluate",{expression:"(() => {\n const visible = (node) => {\n if (!node) return false;\n const style = getComputedStyle(node);\n const rect = node.getBoundingClientRect();\n return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0;\n };\n const boot = document.querySelector('#boot');\n const meaningful = [...document.querySelectorAll('main,article,section,img,video,canvas,svg,[role=\"dialog\"],[role=\"main\"],[role=\"img\"]')]\n .filter((node) => !node.closest('#boot') && visible(node));\n return {\n title: document.title || '', url: location.href,\n hasBody: !!document.body,\n textLength: (document.body?.innerText || '').trim().length,\n meaningfulVisualCount: meaningful.length,\n visibleBoot: visible(boot),\n visibleLoading: [...document.querySelectorAll('span')].some((node) => {\n const text = (node.textContent || '').trim();\n return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test(text);\n }),\n signedOutInvite: !!document.querySelector('#invitation-room-title'),\n prerenderGeo: !!document.querySelector('[data-prerender=\"geo\"]'),\n };\n })()",returnByValue:!0},t);if(r.exceptionDetails)throw new Error("Could not inspect the rendered preview state.");return r.result?.value||null}async freezeCurrentPage(e,t){const r=await e.send("Runtime.evaluate",{expression:"(async () => {\n const clone = document.documentElement.cloneNode(true);\n clone.querySelectorAll('script,link[rel=\"modulepreload\"],link[rel=\"preload\"]').forEach((node) => node.remove());\n clone.querySelectorAll('*').forEach((node) => {\n for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);\n if (node instanceof HTMLInputElement) node.removeAttribute('value');\n if (node instanceof HTMLTextAreaElement) node.textContent = '';\n });\n const css = [];\n for (const sheet of [...document.styleSheets]) {\n try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}\n }\n clone.querySelectorAll('style,link[rel=\"stylesheet\"]').forEach((node) => node.remove());\n const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);\n const style = document.createElement('style');\n style.setAttribute('data-thinkpool-snapshot', '');\n style.textContent = css.join('\\n');\n head.appendChild(style);\n\n const assetUrls = new Set();\n const remember = (raw) => {\n if (!raw || /^(data:|blob:|#)/i.test(raw)) return;\n try {\n const absolute = new URL(raw, location.href);\n if (absolute.origin === location.origin) assetUrls.add(absolute.href);\n } catch {}\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n remember(node.getAttribute('src'));\n remember(node.getAttribute('poster'));\n });\n for (const match of style.textContent.matchAll(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);\n\n const replacements = new Map();\n let inlinedBytes = 0;\n for (const absolute of assetUrls) {\n try {\n const response = await fetch(absolute);\n const buffer = await response.arrayBuffer();\n if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));\n replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));\n inlinedBytes += buffer.byteLength;\n } catch {}\n }\n const replaceAsset = (raw) => {\n try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));\n node.removeAttribute('srcset');\n });\n style.textContent = style.textContent.replace(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url(\"' + replaceAsset(raw) + '\")');\n return '<!doctype html>\\n' + clone.outerHTML;\n })()",awaitPromise:!0,returnByValue:!0},t);if(r.exceptionDetails||"string"!=typeof r.result?.value){const e=r.exceptionDetails?.exception?.description||r.exceptionDetails?.text||r.result?.description;throw new Error("Could not freeze the rendered preview for Design"+(e?`: ${e}`:"."))}const o=r.result.value;if(!o.trim()||Buffer.byteLength(o)>19e5)throw new Error("The rendered preview is too large to make editable.");return o}async capture({url:e,viewport:t,fullPage:r=!0,waitMs:o=300,includeSnapshot:i=!1,auth:n=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:n},async({pipe:e,sessionId:o})=>{const n=await this.renderedState(e,o),s=await e.send("Page.getLayoutMetrics",{},o),a=Math.ceil(s.cssContentSize?.height||t.height),l=r?Math.min(2e4,Math.max(t.height,a)):t.height,c=await e.send("Page.captureScreenshot",{format:"png",fromSurface:!0,captureBeyondViewport:!0,clip:{x:0,y:0,width:t.width,height:l,scale:1}},o),u=await this.renderedState(e,o),d=i?await this.freezeCurrentPage(e,o):null;return{png:Buffer.from(c.data,"base64"),width:t.width,height:l,contentHeight:a,capped:a>2e4,beforeState:n,afterState:u,snapshot:d}})}async snapshot({url:e,viewport:t=DEFAULT_VIEWPORTS.desktop,waitMs:r=300,auth:o=null}){return this.withPage({url:e,viewport:t,waitMs:r,auth:o},({pipe:e,sessionId:t})=>this.freezeCurrentPage(e,t))}async inspect({url:e,viewport:t=DEFAULT_VIEWPORTS.mobile,selector:r,waitMs:o=300,auth:i=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:i},async({pipe:e,sessionId:t})=>{const o=`(() => {\n const selector = ${JSON.stringify(r||null)};\n const el = selector ? document.querySelector(selector) : null;\n const r = el && el.getBoundingClientRect();\n return {\n title: document.title, url: location.href,\n viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio, touchPoints: navigator.maxTouchPoints, coarsePointer: matchMedia('(pointer: coarse)').matches, userAgent: navigator.userAgent },\n document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },\n selector: selector ? { query: selector, found: !!el, tag: el?.tagName || null, text: el?.innerText?.slice(0, 2000) || '', rect: r ? { x:r.x, y:r.y, width:r.width, height:r.height } : null } : null,\n text: document.body?.innerText?.slice(0, 12000) || ''\n };\n })()`,i=await e.send("Runtime.evaluate",{expression:o,returnByValue:!0},t);if(i.exceptionDetails)throw new Error("DOM inspection failed in the preview page.");return i.result?.value||{}})}async close(){if(clearTimeout(this.idleTimer),this.idleTimer=null,this.closing)return this.closing;const e=this.child,t=this.pipe,o=this.profileDir;return this.child=null,this.pipe=null,this.profileDir=null,this.closing=(async()=>{if(t&&null==e?.exitCode&&null==e?.signalCode&&await t.send("Browser.close").catch(()=>{}),e&&null==e.exitCode)try{e.kill("SIGTERM")}catch{}o&&await r.rm(o,{recursive:!0,force:!0}).catch(()=>{})})().finally(()=>{this.closing=null}),this.closing}}export const sharedViewportBrowser=new CdpBrowser;export class ViewportManager{constructor({workspaceRoot:e,ownerId:t,roomCode:r,outbox:s,browser:a=sharedViewportBrowser,startPreviewImpl:c=l,designContext:u=null,authContext:d=null}={}){this.workspaceRoot=i.resolve(e||process.cwd()),this.ownerId=t||n(),this.roomCode="string"==typeof r&&r.trim()?r.trim():null,this.outbox=s||i.join(o.tmpdir(),"thinkpool-viewport-captures",this.ownerId),this.browser=a,this.startPreviewImpl=c,this.designContext="function"==typeof u?u:()=>null,this.authContext="function"==typeof d?d:()=>null,this.previewId=`viewport:${this.ownerId}`,this.preview=null,this.root=null}setWorkspaceRoot(e){if(!e)throw new Error("A lane workspace is required.");return this.workspaceRoot=i.resolve(e),this.workspaceRoot}async start({root:e="dist"}={}){const t=await resolveContainedRoot(this.workspaceRoot,e);await this.stop();const r=await this.startPreviewImpl({dir:t,host:"127.0.0.1",port:0,id:this.previewId}),o=new URL(r.url);if("127.0.0.1"!==o.hostname&&"localhost"!==o.hostname)throw await(r.stop?.()),new Error("Preview server did not bind to loopback.");return this.preview=r,this.root=t,{url:r.url,root:t}}requirePreview(){if(!this.preview?.url)throw new Error("No preview is running. Call preview_start after building the app.");return this.preview}defaultRoute(){return this.roomCode?`/?r=${encodeURIComponent(this.roomCode)}`:"/"}resolveRoute(e){return normalizeRoute(e??this.defaultRoute())}pageUrl(e){const t=this.requirePreview();return new URL(normalizeRoute(e),t.url).href}async previewAuth(e){if(!routeNeedsPreviewAuth(e))return null;let t=this.authContext()||{};if(!t.accessToken)return null;const r=Date.now()+15e3;for(;;){const e=previewAuthBootstrap({...t,minTtlSec:30});if(e)return e;if(Date.now()>=r)throw new Error("The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.");await new Promise(e=>setTimeout(e,250)),t=this.authContext()||{}}}async capture({route:e,viewports:t="both",title:o="Viewport capture",fullPage:n=!0,waitMs:s=300,card:a=!1}={}){const l=this.resolveRoute(e),c=this.pageUrl(l),u="both"===t?["desktop","mobile"]:[t];if(u.some(e=>!DEFAULT_VIEWPORTS[e]))throw new Error('viewports must be "both", "desktop", or "mobile".');const d=`${safeSlug(o)}-${Date.now().toString(36)}`,h=await r.realpath(this.workspaceRoot),p=i.relative(h,this.root)||".",w=JSON.stringify([p,l]),m=this.designContext()||null,f=m?.captureKey===w?{designRequestId:m.requestId,parentRevision:m.parentRevision}:{},g=!0===a||!!f.designRequestId,b={};for(const e of u)b[e]=await this.browser.capture({url:c,viewport:DEFAULT_VIEWPORTS[e],fullPage:n,waitMs:s,includeSnapshot:g&&e===(u.includes("desktop")?"desktop":u[0]),auth:await this.previewAuth(l)});const v=g&&!(!b.desktop||!b.mobile);if(v||routeNeedsPreviewAuth(l))for(const[e,t]of Object.entries(b)){const r=previewArtifactIssue(l,t);if(r)throw new Error(`${e} preview rejected: ${r} No screenshot or mockup card was created.`)}const y=b.desktop?.snapshot||b.mobile?.snapshot||null;if(v&&("string"!=typeof y||!y.trim()))throw new Error("The deliverable could not freeze its rendered source. No mockup card was created.");await r.mkdir(this.outbox,{recursive:!0});for(const[e,t]of Object.entries(b))t.file=i.join(this.outbox,`${d}--${e}.png`),await r.writeFile(t.file,t.png);const x=v?i.join(this.outbox,`${d}--source.html`):null;x&&await r.writeFile(x,y);let S=null;if(v){const e=await resolvePreviewSourceMap(this.root),t={slug:d,title:String(o||"Viewport capture").slice(0,120),desktop:b.desktop.file,mobile:b.mobile.file,ts:Date.now(),sourceKind:"preview",deliveryIntent:"card",readinessValidated:!0,snapshot:x,previewRoot:p,route:l,captureKey:w,...e?{sourceMap:e}:{},...f};S=i.join(this.outbox,`${d}.json`);const n=`${S}.tmp`;await r.writeFile(n,JSON.stringify(t)),await r.rename(n,S)}return{url:c,route:l,slug:d,manifestPath:S,captures:b,snapshotPath:x,cardReady:v}}async inspect({route:e,viewport:t="mobile",selector:r,waitMs:o=300}={}){if(!DEFAULT_VIEWPORTS[t])throw new Error('viewport must be "desktop" or "mobile".');const i=this.resolveRoute(e);return this.browser.inspect({url:this.pageUrl(i),viewport:DEFAULT_VIEWPORTS[t],selector:r,waitMs:o,auth:await this.previewAuth(i)})}async stop(){const e=this.preview||a.get(this.previewId);this.preview=null,this.root=null,e?.stop&&await e.stop()}}const w=e=>({content:[{type:"text",text:e}]}),m=e=>w(`Viewport error: ${e?.message||e}`);export function createViewportTools({tool:e,z:t,manager:r}){return[e("preview_start","Start a bridge-owned, read-only localhost preview for built files inside this lane workspace. Run the project build first. The default root is dist; pass another relative directory only when its index.html is the intended preview.",{root:t.string().max(240).optional().describe("relative directory inside this lane workspace; default: dist")},async e=>{try{const t=await r.start({root:e?.root||"dist"});return w(`Preview ready from ${i.relative(r.workspaceRoot,t.root)||"."}. Use preview_capture to see desktop/mobile, or preview_inspect for DOM text and geometry.`)}catch(e){return m(e)}}),e("preview_capture","Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Omit path to preview the current authenticated room; pass / explicitly for the public landing page. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewports:t.enum(["both","desktop","mobile"]).optional(),title:t.string().max(120).optional(),fullPage:t.boolean().optional(),card:t.boolean().optional().describe("default false; true only for an intentional user-facing mockup/Design deliverable"),waitMs:t.number().int().min(0).max(d).optional().describe("settle time after load, max 5000ms")},async e=>{try{const t=await r.capture({route:e?.path,viewports:e?.viewports||"both",title:e?.title||"Viewport capture",fullPage:!1!==e?.fullPage,waitMs:e?.waitMs??300,card:!0===e?.card}),o=t.cardReady?"The complete room preview card is queued after your final response.":"This is verification evidence only; no room card was created.",i=[{type:"text",text:`Captured ${Object.keys(t.captures).join(" + ")} for ${t.route}. ${o}`}];for(const[e,r]of Object.entries(t.captures))i.push({type:"text",text:`${e}: ${r.width}×${r.height}${r.capped?" (height capped)":""}`}),i.push({type:"image",data:r.png.toString("base64"),mimeType:"image/png"});return{content:i}}catch(e){return m(e)}}),e("preview_inspect","Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Omit path for the current authenticated room; pass / explicitly for the public landing page. Returns page text, document dimensions, and optional selector text/geometry without changing the page.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewport:t.enum(["desktop","mobile"]).optional(),selector:t.string().max(500).optional(),waitMs:t.number().int().min(0).max(d).optional()},async e=>{try{const t=await r.inspect({route:e?.path,viewport:e?.viewport||"mobile",selector:e?.selector,waitMs:e?.waitMs??300});return w(JSON.stringify(t,null,2))}catch(e){return m(e)}}),e("preview_stop","Stop this lane's bridge-owned preview server and release its port.",{},async()=>{try{return await r.stop(),w("Preview stopped.")}catch(e){return m(e)}})]}
|