cccc console

bluebut#53stalled

cb40ccb2-6ce4-41e8-8ae4-e3e938edd2bc · pbox · Jul 24, 03:03:45 PM26h ago

team
solo
turns
43
tools
42
tokens
2.1M
spend
$1.89
took
5m 4s
model
claude-opus-4-8
errors
1
claude --resume cb40ccb2-6ce4-41e8-8ae4-e3e938edd2bc

lead agent

claude-opus-4-8

stalled
GOAL: fix GitLab issue #53 in devdashco/bluebut in an isolated worktree, push, LAND IT ON main, and close. SUBAGENTS: spawn AT MOST 4 parallel subagent(s) (Task tool) at a time. NEVER fan heavy commands (installs, full test suites, builds) out across subagents in parallel — that is what melts the box. You are in the devdashco/bluebut repository (you are already assigned to this issue). This repo lives on our self-hosted GitLab (gitlab.hostbun.cc) — use the `glab` CLI for all issue / MR work (it is installed and a

Jul 24, 03:03:45 PM

Bash{"command": "pwd && git branch --show-current && PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53' && $PROG read start && git fetch origin -q && git log --oneline origin/main..fix/issue-53 2>/dev/null && echo \"---MRs---\" && glab mr list --repo devdashco/bluebut --source-branch fix/issue-53 2>/dev/null", "description": "Confirm worktree, branch, existing work"}
/home/philip/Documents/GitHub/bluebut-issue-53
fix/issue-53
---MRs---
No open merge requests match your search in devdashco/bluebut.
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts"}
1	// Accounts — the action layer extracted from Accounts.tsx: all the per-account
2	// verb handlers (Smart Load, mint, bank, type-login, auto-login, refresh, save
3	// creds, connect-page) plus the state they own (results, loading/typing flags,
4	// mint/proxy-pick state, in-app Control). The parent computes the read-side maps
5	// (sessByAcct, cloakByAcct, fleet…) and passes them in; this hook returns every
6	// piece of state + handler the parent's render / rows / detail view consume.
7	import { useState, useCallback, useRef, useEffect } from "react";
8	import toast from "react-hot-toast";
9	import { useMutation, type QueryClient } from "@tanstack/react-query";
10	import {
11	  runCliLive, createAccount, upsertAccount, checkAccountsHealth, startProfile,
12	  mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,
13	  connectMessenger,
14	  type Session, type ProxybunProxy, type ProxyCheck, type CloakboxRow, type CloakboxCatalog,
15	} from "../../api";
16	import { store } from "../../api/store";
17	import { useJobs } from "../../state/jobs";
18	import { accountLabel, isBurnedStatus } from "../../lib/util";
19	import { proxyEgressKey } from "./helpers";
20	
21	type Sharer = { account: string; burned: boolean; status: string | null };
22	type ResultRow = { status: string; ip: string; country: string; display_name?: string; account_type?: string; error?: string; egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null };
23	
24	export interface UseAccountActionsArgs {
25	  sessions: Session[];
26	  sessByAcct: Record<string, Session>;
27	  cloakByAcct: Record<string, CloakboxRow>;
28	  cloakCat: CloakboxCatalog | undefined;
29	  fleet: ProxybunProxy[];
30	  acctsByProxyEgress: Record<string, Sharer[]>;
31	  instByAcct: Record<string, { id: string | null; seed: string | null }>;
32	  selectedAccount: string | null;
33	  onSelectAccount?: (a: string | null) => void;
34	  qcAccounts: QueryClient;
35	  setShowAdd: (v: boolean) => void;
36	  startJob: ReturnType<typeof useJobs>["startJob"];
37	}
38	
39	export function useAccountActions(args: UseAccountActionsArgs) {
40	  const {
41	    sessions, sessByAcct, cloakByAcct, cloakCat, fleet, acctsByProxyEgress,
42	    instByAcct, selectedAccount, onSelectAccount, qcAccounts, setShowAdd, startJob,
43	  } = args;
44	
45	  const [loadingAcct, setLoadingAcct]   = useState<string | null>(null);
46	  const [typingAcct, setTypingAcct]     = useState<string | null>(null);
47	  const [autoLoginAcct, setAutoLoginAcct] = useState<string | null>(null);
48	  const [savingAcct, setSavingAcct]     = useState<string | null>(null);
49	  const [results, setResults]           = useState<Record<string, ResultRow>>({});
50	
51	  // ── Add-account modal state ────────────────────────────────────────────
52	  const [adding, setAdding] = useState(false);
53	  const [addForm, setAddForm] = useState({ account: "", display_name: "", fb_email: "", fb_phone: "", fb_password: "" });
54	  const addSlugValid = /^[a-z0-9][a-z0-9_-]{1,39}$/.test(addForm.account);
55	  const addSlugDup = sessions.some(s => s.account === addForm.account);
56	
57	  // ── Guided-Load / proxy state ──────────────────────────────────────────
58	  const [mintSlug, setMintSlug] = useState("");
59	  const [mintProxyId, setMintProxyId] = useState("");   // fleet proxy id chosen for a proxyless account
60	  const [minting, setMinting] = useState(false);
61	  const [mintErr, setMintErr] = useState<string | null>(null);
62	  const [banking, setBanking] = useState(false);
63	  const [bankErr, setBankErr] = useState<string | null>(null);
64	  const [showProxyPick, setShowProxyPick] = useState(false);
65	  const [mintedSeed, setMintedSeed] = useState<{ acct: string; seed: string } | null>(null);
66	  const [loadedSeed, setLoadedSeed] = useState<{ acct: string; seed: string } | null>(null);
67	  const [mintProxyCheck, setMintProxyCheck] = useState<ProxyCheck | null>(null);
68	  const [verifyingProxy, setVerifyingProxy] = useState(false);
69	  const mintProxy = fleet.find(p => p.id === mintProxyId) || null;
70	  // Request token: only the LATEST verify may apply its verdict.
71	  const verifySeq = useRef(0);
72	  const doVerifyProxy = useCallback(async (id: string) => {
73	    if (!id) return;
74	    const seq = ++verifySeq.current;
75	    setVerifyingProxy(true); setMintErr(null);
76	    let result: ProxyCheck;
77	    try { result = await verifyProxy(id); }
78	    catch (e) { result = { ok: false, id, egress_ip: null, reputation: null, error: e instanceof Error ? e.message : String(e) }; }
79	    if (seq !== verifySeq.current) return; // a newer verify superseded this one — drop the stale verdict
80	    setMintProxyCheck(result);
81	    setVerifyingProxy(false);
82	  }, []);
83	  // Any change to the chosen proxy (or account) invalidates a prior verification
84	  // and AUTO-fires a fresh live test — no manual "Test proxy" click.
85	  useEffect(() => {
86	    setMintProxyCheck(null);
87	    if (mintProxyId) void doVerifyProxy(mintProxyId);
88	  }, [mintProxyId, mintSlug, doVerifyProxy]);
89	  const mintProxyVerified = !!mintProxyCheck && mintProxyCheck.ok && mintProxyCheck.id === mintProxyId;
90	  const mintProxyEgress = proxyEgressKey(mintProxy?.host, mintProxy?.port);
91	  const mintProxySharers = (mintProxyEgress ? (acctsByProxyEgress[mintProxyEgress] ?? []).filter(x => x.account !== mintSlug) : []);
92	
93	  async function submitAdd() {
94	    if (!addSlugValid || addSlugDup || adding) return;
95	    const acct = addForm.account;
96	    setAdding(true);
97	    const job = startJob({
98	      label: `Add account · ${acct}`, account: acct,
99	      site: "facebook", cmd: "POST /accounts/create", args: [],
100	    });
101	    try {
102	      job.event("Creating account — keyvault stores identity (mint a device to run it)");
103	      await createAccount(acct, {
104	        display_name: addForm.display_name.trim() || undefined,
105	        fb_email: addForm.fb_email.trim() || undefined,
106	        fb_phone: addForm.fb_phone.trim() || undefined,
107	        fb_password: addForm.fb_password || undefined,
108	      });
109	      await Promise.all([
110	        qcAccounts.invalidateQueries({ queryKey: ["sessions"] }),
111	        qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] }),
112	      ]);
113	      job.succeed(`added — mint a Windows device to make it runnable`);
114	      setShowAdd(false);
115	      setAddForm({ account: "", display_name: "", fb_email: "", fb_phone: "", fb_password: "" });
116	    } catch (e) {
117	      job.fail(String(e));
118	    }
119	    setAdding(false);
120	  }
121	
122	  // Inline edits (password / email / phone) — one mutation backs all four fields.
123	  const saveAccountField = useMutation({
124	    mutationFn: (vars: { acct: string; patch: Record<string, unknown> }) => upsertAccount(vars.acct, vars.patch),
125	    onSuccess: () => { qcAccounts.invalidateQueries({ queryKey: ["sessions"] }); },
126	  });
127	  async function saveField(acct: string, patch: Record<string, unknown>) {
128	    try {
129	      await saveAccountField.mutateAsync({ acct, patch });
130	    } catch (e) {
131	      console.error(e);
132	      toast.error(`Couldn’t save ${acct}: ${(e as Error)?.message || e}`);
133	    }
134	  }
135	  async function savePassword(acct: string, val: string) {
136	    await saveField(acct, { fb_password: val || null });
137	  }
138	  async function saveIdField(acct: string, field: "fb_email" | "fb_phone", val: string) {
139	    await saveField(acct, { [field]: val.trim() || null });
140	  }
141	  async function saveCode(acct: string, val: string) {
142	    await saveField(acct, { fb_2fa_code: val.trim() || null });
143	  }
144	  async function saveTotp(acct: string, val: string) {
145	    await saveField(acct, { fb_totp_secret: val.trim() || null });
146	  }
147	
148	  // Per-profile refresh — re-derives EVERY live truth for ONE account.
149	  const [refreshingAcct, setRefreshingAcct] = useState<string | null>(null);
150	  async function refreshAcct(acct: string) {
151	    if (refreshingAcct) return;
152	    setRefreshingAcct(acct);
153	    try {
154	      const { accounts } = await checkAccountsHealth(acct);
155	      const a = accounts[0];
156	      if (a) {
157	        setResults(prev => ({
158	          ...prev,
159	          [acct]: {
160	            status: a.status,
161	            ip: a.egress_ip ?? (a.via === "proxy" ? "proxy" : "box"),
162	            country: a.error ? `err: ${a.error.slice(0, 40)}` : "—",
163	            display_name: a.name ?? undefined,
164	            egress_ip: a.egress_ip ?? null,
165	            via: a.via,
166	            device: a.device ?? null,
167	            error: a.error ?? undefined,
168	          },
169	        }));
170	        qcAccounts.invalidateQueries({ queryKey: ["sessions"] }); // sticky-green writeback may have bumped last_status
171	        qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] }); // re-read jar + FB-confirmed date → fresh Cookie-truth + Verified
172	      }
173	    } catch (e) {
174	      setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: String(e).slice(0, 80) } }));
175	    } finally {
176	      setRefreshingAcct(null);
177	    }
178	  }
179	
180	  // ── In-app Control of the account's live cloakbox Chrome ────────────────
181	  const [controlling, setControlling] = useState(false);
182	  const wantControlRef = useRef<string | null>(null);
183	  const autoControlledRef = useRef<string | null>(null);
184	  useEffect(() => {
185	    autoControlledRef.current = null;   // new navigation → allow one auto-open decision
186	    if (selectedAccount && wantControlRef.current === selectedAccount) {
187	      setControlling(true);
188	      wantControlRef.current = null;
189	      autoControlledRef.current = selectedAccount;   // came from a live Browser click → handled
190	    } else {
191	      setControlling(false);
192	    }
193	  }, [selectedAccount]);
194	  // AUTO-CHECK: landing on an account that ALREADY has a live cloakbox browser
195	  // opens Control on its own — no manual click.
196	  useEffect(() => {
197	    if (!selectedAccount || autoControlledRef.current === selectedAccount) return;
198	    if (instByAcct[selectedAccount]?.seed) {
199	      autoControlledRef.current = selectedAccount;
200	      setControlling(true);
201	    }
202	  }, [selectedAccount, instByAcct]);
203	  // Open the account's LIVE cloakbox window in the IN-APP Control (HTML
204	  // screencast) — NOT a noVNC tab. Navigate to the account's detail page and
205	  // auto-enable Control on arrival (or toggle it on if already there).
206	  const onOpenBrowser = useCallback((row: { account: string }) => {
207	    if (selectedAccount === row.account) { setControlling(true); return; }
208	    wantControlRef.current = row.account;
209	    onSelectAccount?.(row.account);
210	  }, [selectedAccount, onSelectAccount]);
211	
212	  // Connect — a direct cdp_load that pins the live tab into the Control mirror.
213	  async function connectSession(acct: string) {
214	    setLoadingAcct(acct);
215	    setControlling(true);
216	    try {
217	      const r = await startProfile(acct, "https://www.facebook.com/");
218	      if (r.ok) {
219	        if (r.seed) setLoadedSeed({ acct, seed: r.seed });
220	        setResults(prev => ({ ...prev, [acct]: { status: "loaded", ip: "?", country: "?" } }));
221	      } else {
222	        toast.error(`Load failed: ${r.error ?? "unknown"}`);
223	        setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: r.error } }));
224	      }
225	    } catch (e) {
226	      toast.error(String(e));
227	      setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: String(e) } }));
228	    }
229	    setLoadingAcct(null);
230	  }
231	
232	  // ── Smart Load — the ONE guided "get me driving this account" verb ─────────
233	  function smartLoad(acct: string) {
234	    const s = sessByAcct[acct];
235	    if (isBurnedStatus(s?.last_status) || isBurnedStatus(s?.kv_status) || !!s?.checkpoint_type) {
236	      toast.error(`${acct} is burned — clear the FB wall over VNC before loading.`);
237	      return;
238	    }
239	    if (!s?.proxy) {
240	      // Step 1 — no egress IP. Open the picker; continueLoad fires on assign.
241	      setMintSlug(acct); setMintProxyId(""); setMintProxyCheck(null); setShowProxyPick(true);
242	      return;
243	    }
244	    void continueLoad(acct);
245	  }
246	  // Steps 2–4, after a proxy is guaranteed.
247	  async function continueLoad(acct: string) {
248	    const cb = cloakByAcct[acct];
249	    if (cloakCat?.enabled && (cb?.device_count ?? 0) === 0) { await smartMint(acct); return; }
250	    await connectSession(acct);
251	  }
252	  // Step 3 — mint a Windows device on the account's pinned proxy, then drop the
253	  // operator into the in-app Control mirror on FB's login (creds pre-filled).
254	  const mintTypeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
255	  useEffect(() => () => { if (mintTypeTimer.current) clearTimeout(mintTypeTimer.current); }, []);
256	  async function smartMint(acct: string) {
257	    if (minting) return;
258	    setControlling(true);
259	    setMinting(true); setMintErr(null);
260	    const job = startJob({ label: `Mint & load · ${acct}`, account: acct, site: "facebook", cmd: "POST /accounts/mint", args: ["--account", acct] });
261	    job.event("Minting a Windows cloakbox device on the account's proxy…");
262	    try {
263	      const r = await mintAccount(acct);
264	      setMintedSeed({ acct, seed: r.seed });
265	      qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
266	      job.warn(`device minted (seed ${r.seed}) — log in in the Control mirror, then Save cookies`);
267	      toast.success(`${acct}: device minted — finish login in Control below, then Save cookies.`);
268	      if (mintTypeTimer.current) clearTimeout(mintTypeTimer.current);
269	      mintTypeTimer.current = setTimeout(() => { void typeLoginInto(acct); }, 1800);
270	    } catch (e) {
271	      const msg = e instanceof Error ? e.message : String(e);
272	      setMintErr(msg); job.fail(msg);
273	      toast.error(`Mint failed for ${acct}: ${msg}`);
274	    }
275	    setMinting(false);
276	  }
277	  // Bank the live cookie jar after the inline Control login. This banks COOKIES
278	  // only (the reliable live capture); the udd on-disk session converges separately
279	  // on stop/heartbeat — hence the "Save cookies" label, not "Save session".
280	  async function saveMintedSession(acct: string) {
281	    const seed = mintedSeed?.acct === acct ? mintedSeed.seed : null;
282	    if (!seed || banking) return;
283	    setBanking(true); setBankErr(null);
284	    try {
285	      await bankMintedSession(acct, seed);
286	      toast.success(`${acct}: cookies banked ✓ — device is now runnable (udd converges on stop).`);
287	      setMintedSeed(null);
288	      qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
289	      qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
290	      qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] }); // re-read jar so the cookie/jar panel isn't stale after Save session
291	    } catch (e) {
292	      const msg = e instanceof Error ? e.message : String(e);
293	      setBankErr(msg);
294	      toast.error(`Save cookies failed: ${msg}`);
295	    }
296	    setBanking(false);
297	  }
298	  // ── Live-browser verbs sitting next to Load (issue #48) ─────────────────────
299	  const [savingSessionAcct, setSavingSessionAcct] = useState<string | null>(null);
300	  const [connectingE2eeAcct, setConnectingE2eeAcct] = useState<string | null>(null);
301	  const [checkingExtAcct, setCheckingExtAcct] = useState<string | null>(null);
302	
303	  // Save session — bank the LIVE cookie jar of THIS account's running browser to the
304	  // store WITHOUT stopping it (POST /api/store/profiles/:id/save → orchestrator
305	  // /api/save). Cookies only; the udd converges on stop/heartbeat. Needs a live browser.
306	  async function saveLiveSession(acct: string) {
307	    if (savingSessionAcct) return;
308	    const pid = instByAcct[acct]?.id ?? null;
309	    if (!pid) { toast.error(`${acct}: no live browser to save — Load it first.`); return; }
310	    setSavingSessionAcct(acct);
311	    try {
312	      const r = await store.profiles.save(pid);
313	      if (r.ok) {
314	        toast.success(`${acct}: session saved ✓ — ${r.cookies_banked} cookies banked${r.has_datr ? " (datr present)" : ""}.`);
315	        qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
316	        qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] });
317	      } else {
318	        toast.error(`${acct}: save reported not-ok — check the browser is still live.`);
319	      }
320	    } catch (e) {
321	      toast.error(`Save session failed for ${acct}: ${(e as Error)?.message || e}`);
322	    }
323	    setSavingSessionAcct(null);
324	  }
325	
326	  // Create E2E socket — (re)connect this account's encrypted-messaging (whatsmeow)
327	  // socket BROWSER-FREE via the stored CAT (POST /messenger/connect → e2ee-keepalive
328	  // adapter, --account --force). Fire-and-forget: the job shows in the Jobs UI + the
329	  // socket comes up on the next socket-status poll. NEVER a burn signal (inv #5).
330	  async function connectE2ee(acct: string) {
331	    if (connectingE2eeAcct) return;
332	    setConnectingE2eeAcct(acct);
333	    const t = toast.loading(`Creating E2E socket · ${acct} — reconnecting via stored CAT (browser-free)…`);
334	    try {
335	      await connectMessenger(acct);
336	      toast.success(`${acct}: E2E connect triggered — watch the socket status come up (browser-free).`, { id: t });
337	    } catch (e) {
338	      toast.error(`E2E connect failed for ${acct}: ${(e as Error)?.message || e}`, { id: t });
339	    }
340	    setConnectingE2eeAcct(null);
341	  }
342	
343	  // Check extension — is the capture harvester loaded in THIS account's running browser
344	  // actually alive/reachable? Reads the orchestrator's per-browser heartbeat snapshot
345	  // (GET /api/store/harvester/diag) and reports the verdict for this account's profile.
346	  async function checkExtension(acct: string) {
347	    if (checkingExtAcct) return;
348	    const pid = instByAcct[acct]?.id ?? null;
349	    if (!pid) { toast.error(`${acct}: no live browser — Load it first, then check the extension.`); return; }
350	    setCheckingExtAcct(acct);
351	    try {
352	      const d = await store.harvester.diag();
353	      const b = d.browsers.find(x => x.profile_id === pid);
354	      if (!b) {
355	        toast.error(`${acct}: extension NOT reporting — no harvester heartbeat for this browser (never loaded / crashed).`);
356	      } else if (b.live) {
357	        toast.success(`${acct}: extension connected ✓ — beat ${b.beat_age_s}s ago${b.ext_version ? `, v${b.ext_version}` : ""}${b.captures != null ? `, ${b.captures} captures` : ""}.`);
358	      } else {
359	        toast.error(`${acct}: extension STALE — last heartbeat ${b.beat_age_s}s ago (> ${d.beat_live_s}s live window). It may have crashed — try reloading the browser.`);
360	      }
361	    } catch (e) {
362	      toast.error(`Extension check failed for ${acct}: ${(e as Error)?.message || e}`);
363	    }
364	    setCheckingExtAcct(null);
365	  }
366	
367	  // Assign the chosen fleet proxy to the account (keyvault) and resume the chain.
368	  async function assignProxyAndResume(acct: string, proxyId: string) {
369	    const p = fleet.find(x => x.id === proxyId);
370	    if (!p) return;
371	    try {
372	      await setAccountProxy(acct, { scheme: p.scheme ?? "http", host: p.host, port: p.port, username: p.username ?? "", password: p.password ?? "" });
373	      await qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
374	      toast.success(`Proxy ${p.host}:${p.port} assigned to ${acct}.`);
375	      setShowProxyPick(false);
376	      void continueLoad(acct);
377	    } catch (e) {
378	      toast.error(`Proxy assign failed: ${(e as Error)?.message || e}`);
379	    }
380	  }
381	
382	  // Type login — fill the account's stored creds into its LIVE cloakbox window.
383	  async function typeLoginInto(acct: string) {
384	    if (typingAcct) return;
385	    setTypingAcct(acct);
386	    try {
387	      const r = await typeLogin(acct);
388	      toast.success(`Typed ${r.typed.join(" + ")} into ${acct}'s window (port ${r.instance}) — review & click "Logga in" over VNC.`);
389	    } catch (e) {
390	      toast.error(`Type login failed: ${(e as Error)?.message || e}`);
391	    }
392	    setTypingAcct(null);
393	  }
394	
395	  // Auto login — the FULL flow on the account's live cloakbox window.
396	  async function autoLoginInto(acct: string) {
397	    if (autoLoginAcct) return;
398	    setAutoLoginAcct(acct);
399	    const t = toast.loading(`Auto-login · ${acct} — filling, submitting, handling 2FA…`);
400	    try {
401	      const r = await autoLogin(acct);
402	      if (r.logged_in) {
403	        const banked = r.saved ? "cookies banked ✓" : "⚠ NOT banked (cdp_save failed) — try Mint → Save";
404	        toast.success(`${acct} logged in${r.fb_id ? ` (fb_id ${r.fb_id})` : ""}${r.twofa ? ` · 2FA ${r.twofa}` : ""} · ${banked}`, { id: t });
405	        qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
406	        qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
407	        refreshAcct(acct);
408	      } else if (r.outcome === "needs_2fa") {
409	        toast.error(`${acct}: 2FA code required but none stored — add a TOTP secret or code under Credentials, or finish over VNC.`, { id: t });
410	      } else if (r.outcome === "captcha") {
411	        toast.error(`${acct}: captcha — solve it over VNC (Operations → Open live desktop), then Auto login again.`, { id: t });
412	      } else if (r.outcome === "checkpoint") {
413	        toast.error(`${acct}: FB checkpoint — finish it over VNC. If it asks for a selfie/ID it's a hard wall.`, { id: t });
414	      } else {
415	        toast.error(`${acct}: not logged in (${r.outcome}) — finish over VNC.`, { id: t });
416	      }
417	    } catch (e) {
418	      toast.error(`Auto-login failed: ${(e as Error)?.message || e}`, { id: t });
419	    }
420	    setAutoLoginAcct(null);
421	  }
422	
423	  // Connect a PAGE (pointer model) — run the act-as verb AS the manager.
424	  async function savePageSession(pg: Session) {
425	    const manager = pg.managing_account;
426	    const pageId = pg.fb_id;
427	    if (!manager || !pageId) { return; }
428	    const mgrStatus = sessByAcct[manager]?.last_status ?? null;
429	    setSavingAcct(pg.account);
430	    const job = startJob({
431	      label: `Connect page · ${pg.display_name ?? pageId}`, account: manager,
432	      site: "facebook", cmd: "whoami", args: ["--account", manager, "--as-page", String(pageId)],
433	    });
434	    job.event(`Act as Page ${pg.display_name ?? pageId} via manager ${accountLabel(manager, sessions)}`);
435	    try {
436	      if (mgrStatus !== "logged_in") {
437	        job.event(`manager ${accountLabel(manager, sessions)} is ${mgrStatus ?? "unknown"} → connecting it first`);
438	        const m = await runCliLive("facebook", "load-session", ["--account", manager, "--auto-reauth", "true"], { timeout: 180, jobId: job.id, onEvent: job.event });
439	        if (m.status !== "logged_in") {
440	          job.fail(`manager ${accountLabel(manager, sessions)} could not log in (status=${m.status ?? "?"}) — a Page can't be acted-as without a logged-in manager`, { diagnostic: m.diagnostic });
441	          setSavingAcct(null); return;
442	        }
443	        job.event(`manager logged in (fb_id=${m.fb_id ?? "?"}) → switching into Page`);
444	      }
445	      const r = await runCliLive("facebook", "whoami", ["--account", manager, "--as-page", String(pageId)], { timeout: 180, jobId: job.id, onEvent: job.event })
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53/ui && grep -n \"startProfile\\|login_ok\\|cookies_injected\" src/api.ts src/api/*.ts 2>/dev/null | head -40", "description": "Find startProfile definition"}
src/api/instances.ts:137:export async function startProfile(account: string, url: string): Promise<CloakOpenResult> {
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "offset": 80, "limit": 120}
80	  // download (Lane B) = full-fidelity: download each closed .har.gz, stream-parse, run
81	  // ALL wires on ALL layers untruncated. Default is the download lane.
82	  u.searchParams.set("mode", opts.mode ?? "download");
83	  if (API_TOKEN) u.searchParams.set("token", API_TOKEN);
84	  return u.toString();
85	}
86	// LIVE fleet harvest — every open wiretap session with its full-harvest-layer
87	// counts (HTTP/GraphQL · document bodies · WebSocket frames). Powers the Accounts
88	// page's floating "Live wiretap" card. Refetch on a short interval for a pulse.
89	export interface WiretapLiveSession {
90	  id: string; seed: string | null; account: string | null; slug: string | null;
91	  egress: string | null; proxy: string | null; started_at: string | null;
92	  http: number | null; ws: number | null; docBodies: number | null;
93	  live: boolean; lastActivity: string | null;
94	}
95	export interface WiretapLive {
96	  ok: boolean;
97	  sessions: WiretapLiveSession[];
98	  totals: { sessions: number; http: number; ws: number; docBodies: number } | null;
99	  error?: string;
100	}
101	export async function getWiretapLive(): Promise<WiretapLive> {
102	  try {
103	    const r = await fetch(`${API}/wiretap/live`, { headers: bridgeAuth() });
104	    if (!r.ok) return { ok: false, sessions: [], totals: null };
105	    return await r.json();
106	  } catch { return { ok: false, sessions: [], totals: null }; }
107	}
108	// Hand ONE running browser to the operator on its own display; the backend calls
109	// cdp_focus and returns a self-contained noVNC viewer_url (autoconnect+password).
110	// Prefer the instance PORT (a profile + its act-as-page twin share a seed).
111	// Open ONE FB url INSIDE a chosen account's trusted cloakbox browser (cdp_load
112	// with goto=url), then focus it onto its own noVNC display. The "Open on Facebook"
113	// verb: a bare facebook.com tab is logged-out — the operator wants to see the URL
114	// AS the account. Returns the focused viewer_url (wrap in /desktop for the label).
115	// Slow (~several s: it boots a Windows browser) — callers show a spinner.
116	// Resolve a caller-supplied HANDLE (slug, profile_id, or legacy seed) → the store's
117	// profile_id. The /api/store surface is keyed by profile_id (invariant: the outside sees
118	// profiles), but the UI still holds slugs/seeds in places — one lookup bridges them until
119	// the seed→profile_id purge lands. Returns null if nothing matches.
120	async function resolveStoreProfileId(handle: string): Promise<string | null> {
121	  const r = await fetch(`${API}/api/store/profiles`, { headers: bridgeAuth() });
122	  if (!r.ok) return null;
123	  const d = await r.json().catch(() => null) as { ok?: boolean; profiles?: Array<Record<string, unknown>> } | null;
124	  const list = d?.profiles ?? [];
125	  const h = String(handle);
126	  const hit = list.find((p) =>
127	    p.id === h || p.slug === h ||
128	    (p.fingerprint && typeof p.fingerprint === "object" && String((p.fingerprint as { seed?: unknown }).seed) === h));
129	  return hit ? String(hit.id) : null;
130	}
131	
132	export interface CloakOpenResult { ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string }
133	// Load an account's trusted browser and return its per-profile noVNC viewer. Repointed to
134	// the /api/store engine seam: resolve slug→profile_id, POST /profiles/:id/start (launch),
135	// hand back the view_url. The engine renders the RESTORED session; the operator drives the
136	// target url in the noVNC view (the old cloakbox/open + in-browser goto is retired).
137	export async function startProfile(account: string, url: string): Promise<CloakOpenResult> {
138	  try {
139	    const pid = await resolveStoreProfileId(account);
140	    if (!pid) return { ok: false, account, error: `no store profile for "${account}"` };
141	    // 90s cap — booting the browser is slow but must never spin "Booting…" forever.
142	    // `goto` = where to land AFTER cookies are injected — else the browser parks on the
143	    // egress-check page (api.ipify.org) instead of the account. The engine navigates there
144	    // post-inject, so the account loads logged-in.
145	    const r = await fetch(`${API}/api/store/profiles/${encodeURIComponent(pid)}/start`, {
146	      method: "POST", headers: { ...bridgeAuth(), "Content-Type": "application/json" },
147	      body: JSON.stringify({ goto: url }),
148	      signal: AbortSignal.timeout(90000),
149	    });
150	    const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));
151	    if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };
152	    return { ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error };
153	  } catch (e) {
154	    const msg = (e as Error)?.name === "TimeoutError"
155	      ? "load timed out after 90s — the engine never responded (profile/render/proxy unreachable?). Check the backend logs."
156	      : String((e as Error)?.message || e);
157	    return { ok: false, error: msg };
158	  }
159	}
160	// Return the per-profile noVNC viewer for an already-loaded browser. Repointed to
161	// GET /api/store/profiles/:id/view — the operator watches/drives it in the noVNC view
162	// (in-place CDP navigation is retired; the outside sees profiles, not seeds).
163	export async function gotoProfile(seed: string, url: string): Promise<{ ok: boolean; url?: string; error?: string }> {
164	  try {
165	    const pid = await resolveStoreProfileId(seed);
166	    if (!pid) return { ok: false, error: `no store profile for "${seed}"` };
167	    const r = await fetch(`${API}/api/store/profiles/${encodeURIComponent(pid)}/view`, { headers: bridgeAuth() });
168	    const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));
169	    return { ok: !!d?.ok, url: d?.view_url ?? url, error: d?.error };
170	  } catch (e) { return { ok: false, error: String((e as Error)?.message || e) }; }
171	}
172	export async function getWiretapDiagnostics(session: string): Promise<{ ok: boolean; diagnostics: unknown }> {
173	  try {
174	    const r = await fetch(`${API}/wiretap/diagnostics?session=${encodeURIComponent(session)}`, { headers: bridgeAuth() });
175	    if (!r.ok) return { ok: false, diagnostics: null };
176	    return await r.json();
177	  } catch { return { ok: false, diagnostics: null }; }
178	}
179	
180	// LIVE cloakbox browser instances — the cloakbox replacement for the retired
181	// chromebox /chrome/profiles. One row per running Chrome on the box. Powers the
182	// Browser wall.
183	export interface CloakInstance {
184	  account: string | null;   // 'facebook/philip' | null (scratch)
185	  slug: string | null;      // 'philip'
186	  service: string | null;   // 'facebook'
187	  seed: string | null;      // fingerprint — SHARED by a profile and its act-as-page twin
188	  udd: string | null;       // user-data-dir = UNIQUE instance key (twin: 88521__p<pageid>). The screencast handle.
189	  port: number | null;
190	  device: string | null;
191	  owner: string | null;
192	  live: boolean;
193	  pages: number;
194	  proxy: string | null;
195	}
196	export async function listRunningProfiles(): Promise<{ ok: boolean; instances: CloakInstance[] }> {
197	  try {
198	    // Repointed to the /api/store profile repository — the RUNNING profiles ARE the live
199	    // instances (invariant: the outside sees profiles, not seeds/udds). The screencast handle
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/store.ts"}
1	// api/store — the typed client for the /api/store Hono surface (the identity
2	// store: profiles + accounts + proxies · Target Architecture §6c).
3	//
4	// EVERY shape here is z.infer'd from @bluebut/contract — the same schemas the
5	// backend PARSES its responses through — so the two sides cannot disagree
6	// (invariant #20). No hand-written response interfaces in this file, ever.
7	// When a legacy ui/src/api/index.ts caller is repointed at /api/store, its fetch
8	// moves here and its hand-written interface dies.
9	import {
10	  ListProfilesResponse, GetProfileResponse, StartProfileResponse, StopProfileResponse,
11	  ViewProfileResponse, SaveProfileResponse, HarvesterDiagResponse,
12	  ListAccountsResponse, GetAccountResponse, ListInstancesResponse,
13	  ListCaptureProfilesResponse, GetCaptureSummaryResponse, LiveRegistryResponse,
14	  StartSchemaEngineResponse, SchemaEngineStatusResponse,
15	  GetFlowsResponse, type FlowGraph, type FlowGraphNode, type FlowGraphEdge, type FlowNodeLive, type FlowBatches,
16	  CanvasDoc, GetCanvasResponse, type CanvasDoc as CanvasDocT,
17	  type Profile, type Account, type CreateProfileRequest, type UpdateProfileRequest,
18	  type UpsertAccountRequest, type RunningInstance, type CaptureProfile, type CaptureRow,
19	  type LiveBrowser,
20	  type SchemaEngineStatusResponse as SchemaEngineStatus,
21	} from "@bluebut/contract";
22	import { API } from "./index";
23	import { env } from "../env";
24	
25	// Mirror the backend token fallback (API_TOKEN || BRIDGE_TOKEN) and _core.ts: prod
26	// bakes the UI token under VITE_BRIDGE_TOKEN, so read that too or /api/store 401s.
27	const API_TOKEN: string = env("VITE_API_TOKEN") || env("VITE_BRIDGE_TOKEN");
28	const auth = (): Record<string, string> => (API_TOKEN ? { Authorization: `Bearer ${API_TOKEN}` } : {});
29	
30	async function call<T>(parse: { parse: (v: unknown) => T }, path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
31	  const r = await fetch(`${API}/api/store${path}`, {
32	    ...init,
33	    headers: { ...auth(), ...(init?.body ? { "content-type": "application/json" } : {}), ...init?.headers },
34	    signal: AbortSignal.timeout(init?.timeoutMs ?? 30_000),
35	  });
36	  const body: unknown = await r.json();
37	  if (!r.ok) {
38	    const msg = (body as { error?: string })?.error ?? `HTTP ${r.status}`;
39	    throw new Error(`${path}: ${msg}`);
40	  }
41	  return parse.parse(body); // schema-parse IN too — drift is loud on both sides
42	}
43	
44	export type { Profile, Account, RunningInstance, CaptureProfile, CaptureRow, LiveBrowser };
45	export type { FlowGraph, FlowGraphNode, FlowGraphEdge, FlowNodeLive, FlowBatches };
46	export type { CanvasDocT as CanvasDoc };
47	
48	export const store = {
49	  // The acquisition flows as a live node-graph (green/red per element). Poll for real-time.
50	  flows: () => call(GetFlowsResponse, "/flows"),
51	  // The editable canvas overlay — user positions/groups/edges/notes, persisted whole.
52	  canvas: (id = "default") => call(GetCanvasResponse, `/canvas/${id}`),
53	  saveCanvas: (doc: CanvasDocT, id = "default") =>
54	    call(CanvasDoc, `/canvas/${id}`, { method: "PUT", body: JSON.stringify(doc) }),
55	  profiles: {
56	    list: (filter?: { accountId?: string; running?: boolean }) => {
57	      const q = new URLSearchParams();
58	      if (filter?.accountId) q.set("accountId", filter.accountId);
59	      if (filter?.running !== undefined) q.set("running", String(filter.running));
60	      const qs = q.toString();
61	      return call(ListProfilesResponse, `/profiles${qs ? `?${qs}` : ""}`).then((r) => r.profiles);
62	    },
63	    get: (id: string) => call(GetProfileResponse, `/profiles/${id}`).then((r) => r.profile),
64	    create: (req: CreateProfileRequest) =>
65	      call(GetProfileResponse, "/profiles", { method: "POST", body: JSON.stringify(req) }).then((r) => r.profile),
66	    update: (id: string, patch: UpdateProfileRequest) =>
67	      call(GetProfileResponse, `/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }).then((r) => r.profile),
68	    start: (id: string) => call(StartProfileResponse, `/profiles/${id}/start`, { method: "POST" }),
69	    stop: (id: string) => call(StopProfileResponse, `/profiles/${id}/stop`, { method: "POST" }),
70	    view: (id: string) => call(ViewProfileResponse, `/profiles/${id}/view`).then((r) => r.view_url),
71	    /** Bank the LIVE cookie jar (carries datr) of a running browser WITHOUT stopping it. Cookies only. */
72	    save: (id: string) => call(SaveProfileResponse, `/profiles/${id}/save`, { method: "POST" }),
73	  },
74	  /** Capture-extension health per live browser — the harvester heartbeat snapshot (Check-extension verb). */
75	  harvester: {
76	    diag: () => call(HarvesterDiagResponse, "/harvester/diag"),
77	  },
78	  /** Every RUNNING browser (the Browser-wall feed) — engine truth, per-profile noVNC view_url. */
79	  instances: {
80	    list: () => call(ListInstancesResponse, "/instances").then((r) => r.running),
81	  },
82	  /** NATIVE wiretap view — what the engine's CDP tap banked into cloak_captures. */
83	  captures: {
84	    profiles: () => call(ListCaptureProfilesResponse, "/captures/profiles").then((r) => r.profiles),
85	    summary: (id: string) => call(GetCaptureSummaryResponse, `/captures/${id}`),
86	  },
87	  /** Live harvest health per browser — heartbeat ⊕ DB truth (the green-light source). */
88	  live: () => call(LiveRegistryResponse, "/live").then((r) => r.browsers),
89	  /** Wire-schema census over cloak_captures — runs in the background; start then poll status. */
90	  schemaEngine: {
91	    start: () => call(StartSchemaEngineResponse, "/schema-engine/run", { method: "POST" }),
92	    status: (): Promise<SchemaEngineStatus> => call(SchemaEngineStatusResponse, "/schema-engine/status"),
93	  },
94	  accounts: {
95	    list: (service = "facebook") =>
96	      call(ListAccountsResponse, `/accounts?service=${encodeURIComponent(service)}`).then((r) => r.accounts),
97	    upsert: (req: UpsertAccountRequest) =>
98	      call(GetAccountResponse, "/accounts", { method: "POST", body: JSON.stringify(req) }).then((r) => r.account),
99	    setStatus: (id: string, status: string) =>
100	      call(GetAccountResponse, `/accounts/${id}`, { method: "PATCH", body: JSON.stringify({ status }) }).then((r) => r.account),
101	  },
102	};
103
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53 && grep -rn \"StartProfileResponse\\|login_ok\\|cookies_injected\" --include=*.ts packages/ shared/ contract/ 2>/dev/null | grep -i \"start\\|login_ok\\|cookies_inj\" | head -30", "description": "Find StartProfileResponse contract"}
shared/contract/index.ts:106:export const StartProfileResponse = z.object({
shared/contract/index.ts:113:export type StartProfileResponse = z.infer<typeof StartProfileResponse>;
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts", "offset": 95, "limit": 80}
95	});
96	export type CreateProfileRequest = z.infer<typeof CreateProfileRequest>;
97	export const UpdateProfileRequest = z.object({
98	  fingerprint: FingerprintRecipe.optional(),
99	  proxy: z.union([z.string(), z.object({ url: z.string() }), z.null()]).optional(),
100	  labels: z.record(z.string(), z.unknown()).optional(),
101	  status: z.string().optional(),
102	});
103	export type UpdateProfileRequest = z.infer<typeof UpdateProfileRequest>;
104	
105	// POST /api/store/profiles/:id/start → the engine launch result the UI needs
106	export const StartProfileResponse = z.object({
107	  ok: z.literal(true),
108	  cdp_url: z.string(),
109	  view_url: z.string(),
110	  run_id: z.string().optional(),
111	  egress_ip: z.string().nullable().optional(),
112	});
113	export type StartProfileResponse = z.infer<typeof StartProfileResponse>;
114	export const StopProfileResponse = z.object({ ok: z.literal(true) });
115	export type StopProfileResponse = z.infer<typeof StopProfileResponse>;
116	
117	// DELETE /api/store/profiles/:id
118	export const DeleteProfileResponse = z.object({ ok: z.literal(true) });
119	export type DeleteProfileResponse = z.infer<typeof DeleteProfileResponse>;
120	export const ViewProfileResponse = z.object({ ok: z.literal(true), view_url: z.string() });
121	export type ViewProfileResponse = z.infer<typeof ViewProfileResponse>;
122	
123	// POST /api/store/profiles/:id/save → the engine banks the LIVE cookie jar to the
124	// store WITHOUT stopping the browser (orchestrator POST /api/save). Cookies only —
125	// the udd on-disk session converges separately on stop/heartbeat.
126	export const SaveProfileResponse = z.object({
127	  ok: z.boolean(),
128	  profile_id: z.string(),
129	  cookies_banked: z.number(),
130	  has_datr: z.boolean(),
131	  kept_alive: z.boolean(),
132	});
133	export type SaveProfileResponse = z.infer<typeof SaveProfileResponse>;
134	
135	// GET /api/store/harvester/diag → the capture-extension health snapshot the
136	// orchestrator self-reports per live browser (GET /api/harvester/diag). Used by the
137	// "Check extension" button to prove the harvester loaded in a browser is alive.
138	export const HarvesterDiagBrowser = z.object({
139	  profile_id: z.string(),
140	  run_id: z.string().nullable().optional(),
141	  beat_age_s: z.number(),
142	  live: z.boolean(),
143	  ext_version: z.string().nullable().optional(),
144	  captures: z.number().nullable().optional(),
145	  session_ok: z.boolean().nullable().optional(),
146	  diag: z.unknown().optional(),
147	});
148	export type HarvesterDiagBrowser = z.infer<typeof HarvesterDiagBrowser>;
149	export const HarvesterDiagResponse = z.object({
150	  ok: z.literal(true),
151	  now: z.number(),
152	  beat_live_s: z.number(),
153	  count: z.number(),
154	  browsers: z.array(HarvesterDiagBrowser),
155	});
156	export type HarvesterDiagResponse = z.infer<typeof HarvesterDiagResponse>;
157	
158	// GET /api/store/accounts · POST /api/store/accounts (upsert) · PATCH /api/store/accounts/:id
159	export const ListAccountsResponse = z.object({ ok: z.literal(true), accounts: z.array(Account) });
160	export type ListAccountsResponse = z.infer<typeof ListAccountsResponse>;
161	export const GetAccountResponse = z.object({ ok: z.literal(true), account: Account });
162	export type GetAccountResponse = z.infer<typeof GetAccountResponse>;
163	export const UpsertAccountRequest = z.object({
164	  service: z.string().optional(),
165	  login: z.string(),
166	  email: z.string().nullable().optional(),
167	  phone: z.string().nullable().optional(),
168	  status: z.string().optional(),
169	  labels: z.record(z.string(), z.unknown()).optional(),
170	});
171	export type UpsertAccountRequest = z.infer<typeof UpsertAccountRequest>;
172	export const PatchAccountRequest = z.object({ status: z.string() });
173	export type PatchAccountRequest = z.infer<typeof PatchAccountRequest>;
174
Bash{"command": "grep -rn \"login_ok\\|cookies_injected\" --include=*.ts --include=*.py server/ orchestrator/ 2>/dev/null | head -30; echo \"---all---\"; grep -rln \"login_ok\\|cookies_injected\" . 2>/dev/null | grep -v node_modules | head -30", "description": "Find login_ok in server code"}
server/store/engine.ts:38:    cookies_injected: z.number().nullable().optional(),
server/store/engine.ts:39:    login_ok: z.boolean().nullable().optional(),
---all---
cloakbox/orchestrator/launcher.py
cloakbox/orchestrator/session_capture.py
cloakbox/orchestrator/api.py
server/store/engine.ts
.claude/skills/job-architecture/SKILL.md
.claude/skills/run-preamble/SKILL.md
scripts/_lib/adapter-gates.ts
scripts/_lib/adapter-gates.test.mjs
scripts/_lib/adapter-setup.ts
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/server/store/engine.ts", "offset": 20, "limit": 120}
20	// required (nullable where the value can legitimately be null), so a rename that drops a key is a LOUD
21	// throw at the seam, not a silent wrong value. Python enforces the SAME shape via `response_model=`
22	// (StartResp/SaveResp in cloakbox/orchestrator/api.py) — one contract, validated on both sides.
23	// `.loose()` keeps forward-compatible extra keys from failing the parse.
24	export const EngineStartResponse = z
25	  .object({
26	    run_id: z.string(),
27	    profile_id: z.string(),
28	    cdp_url: z.string(),
29	    view_url: z.string(),
30	    gate: z.string().nullable(),
31	    egress_ip: z.string().nullable(),
32	    /** True iff the CDP wiretap tap attached (CLOAKBOX_CAPTURE=1). False = nothing is being captured. */
33	    capture: z.boolean(),
34	    port: z.number().nullable().optional(),
35	    render: z.string().nullable().optional(),
36	    vnc_port: z.number().nullable().optional(),
37	    /** COUNT of cookies injected into the live browser from the banked jar (0 = none/no jar). Not a boolean. */
38	    cookies_injected: z.number().nullable().optional(),
39	    login_ok: z.boolean().nullable().optional(),
40	  })
41	  .loose()
42	export type EngineStart = z.infer<typeof EngineStartResponse>
43	
44	export const EngineSaveResponse = z
45	  .object({
46	    ok: z.boolean(),
47	    profile_id: z.string(),
48	    cookies_banked: z.number(),
49	    has_datr: z.boolean(),
50	    kept_alive: z.boolean(),
51	  })
52	  .loose()
53	export type EngineSave = z.infer<typeof EngineSaveResponse>
54	
55	// Per-path response validators. `call()` runs the matching schema (loud BoxDownError on drift) so
56	// EVERY caller of the engine seam — engine.start here AND cloak-client-core.ts's raw call — is guarded
57	// in ONE place. Dynamic paths (/api/view/<id>) are unvalidated by design.
58	const RESPONSE_SCHEMAS: Record<string, z.ZodType> = {
59	  '/api/start': EngineStartResponse,
60	  '/api/save': EngineSaveResponse,
61	}
62	
63	export interface EngineRunning {
64	  profile_id: string
65	  status: string
66	  labels: Record<string, unknown>
67	  run_id: string
68	  port: number | null
69	  egress_ip: string | null
70	  gate: string | null
71	  started_at: string
72	  proxy: string | null
73	  view_url: string
74	}
75	
76	// The ONE orchestrator HTTP client (invariant #3). Exported so adapters/_lib/cloak-client.ts
77	// delegates here instead of reimplementing the fetch/timeout/error dance (was a copy-paste twin).
78	export async function call<T>(method: 'GET' | 'POST', path: string, body?: Record<string, unknown>): Promise<T> {
79	  let res: Response
80	  try {
81	    res = await fetch(`${BASE}${path}`, {
82	      method,
83	      headers: { ...(body ? { 'content-type': 'application/json' } : {}) },
84	      body: body ? JSON.stringify(body) : undefined,
85	      signal: AbortSignal.timeout(150_000), // a cold cloakserve spawn + gate is legitimately slow
86	    })
87	  } catch (e) {
88	    throw new BoxDownError(
89	      `engine ${method} ${path}: unreachable (${e instanceof Error ? e.message : e}) — is the orchestrator up on ${BASE}?`,
90	      { cause: e, meta: { base: BASE } },
91	    )
92	  }
93	  const text = await res.text().catch(() => '')
94	  let json: Record<string, unknown>
95	  try { json = text ? JSON.parse(text) : {} } catch { throw new BoxDownError(`engine ${path}: non-JSON — ${text.slice(0, 160)}`) }
96	  if (!res.ok) throw new BoxDownError(`engine ${path}: HTTP ${res.status} — ${json.detail || text.slice(0, 200)}`)
97	  // #188.1: validate the cross-runtime response shape (the Python dict) instead of blind-casting, so a
98	  // renamed/dropped key on the orchestrator side is a loud failure here — not a silent undefined.
99	  const schema = RESPONSE_SCHEMAS[path]
100	  if (schema) {
101	    const parsed = schema.safeParse(json)
102	    if (!parsed.success) {
103	      const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
104	      throw new BoxDownError(
105	        `engine ${path}: response contract drift — ${issues}. Got keys: [${Object.keys(json).join(', ')}]. ` +
106	          'The orchestrator (cloakbox/orchestrator/api.py) response shape and this schema disagree.',
107	      )
108	    }
109	    return parsed.data as T
110	  }
111	  return json as T
112	}
113	
114	export const engine = {
115	  /** Launch a profile → { cdp_url, view_url, run_id, gate, egress_ip }. */
116	  start: (profileId: string, opts: Record<string, unknown> = {}) =>
117	    call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
118	  /** Bank the LIVE cookie jar to the store WITHOUT stopping the browser (§7b.8). Cookies only. */
119	  save: (profileId: string) => call<EngineSave>('POST', '/api/save', { profile_id: profileId }),
120	  /** Snapshot the session + end the run. */
121	  stop: (profileId: string) => call<{ ok: boolean }>('POST', '/api/stop', { profile_id: profileId }),
122	  /** Capture-extension health per live browser — the harvester's self-reported heartbeat snapshot. */
123	  harvesterDiag: () => call<{ now: number; beat_live_s: number; count: number; browsers: unknown[] }>('GET', '/api/harvester/diag'),
124	  /** Running profiles (profile_id, run, proxy, gate, labels, per-profile view_url). */
125	  list: () => call<{ running: EngineRunning[] }>('GET', '/api/list').then((r) => r.running),
126	  /** Live registry — every browser's harvester heartbeat merged with DB capture/session truth. */
127	  live: () => call<{ now: number; beat_live_s: number; count: number; browsers: unknown[] }>('GET', '/api/live'),
128	  /** The per-profile noVNC url (+ raises that browser's window). */
129	  view: (profileId: string) => call<{ view_url: string }>('GET', `/api/view/${profileId}`),
130	  /** Liveness + store reachability. */
131	  health: () => call<{ ok: boolean }>('GET', '/api/health'),
132	}
133
Bash{"command": "grep -rn \"profiles/:id/start\\|/start\\b\\|StartProfileResponse\\|view_url\" server/store/*.ts | grep -i \"start\\|route\\|post\" | head; echo \"---\"; ls server/store/", "description": "Find store start route"}
server/store/engine.ts:59:  '/api/start': EngineStartResponse,
server/store/engine.ts:117:    call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
---
accounts-read.ts
accounts.ts
captures.ts
config.ts
contract.test.mjs
db.ts
engine.ts
profiles.ts
schema-engine.ts
tombstone.test.mjs
workflows.test.mjs
workflows.ts
Bash{"command": "grep -n \"start\\|view_url\\|egress_ip\\|login_ok\\|cookies_injected\\|StartProfileResponse\\|\\.post(\" server/store/profiles.ts | head -40", "description": "Find start handler in profiles.ts"}
80:  /** slug → profile_id — the resolution "Load account philip" does before engine.start(). */
82:    // NOT_DELETED — a tombstoned account (issue #22) must not resolve to a startable
Bash{"command": "grep -rn \"/start\\|engine.start\\|view_url\\|profiles/:id\\|StartProfileResponse\" server/**/*.ts 2>/dev/null | grep -iv \"test\" | head -30", "description": "Find route mapping engine.start"}
server/store/engine.ts:4:// is the one place backend code reaches the engine — routes call engine.start(profileId), never the
server/store/engine.ts:29:    view_url: z.string(),
server/store/engine.ts:56:// EVERY caller of the engine seam — engine.start here AND cloak-client-core.ts's raw call — is guarded
server/store/engine.ts:59:  '/api/start': EngineStartResponse,
server/store/engine.ts:73:  view_url: string
server/store/engine.ts:115:  /** Launch a profile → { cdp_url, view_url, run_id, gate, egress_ip }. */
server/store/engine.ts:117:    call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
server/store/engine.ts:124:  /** Running profiles (profile_id, run, proxy, gate, labels, per-profile view_url). */
server/store/engine.ts:129:  view: (profileId: string) => call<{ view_url: string }>('GET', `/api/view/${profileId}`),
server/store/profiles.ts:80:  /** slug → profile_id — the resolution "Load account philip" does before engine.start(). */
Bash{"command": "grep -rln \"engine.start\\|/profiles/:id/start\\|StartProfileResponse\\|goto\" server/ | grep -v node_modules; echo \"---routes---\"; grep -rn \"profiles\" server/routes*.ts server/index.ts server/app.ts 2>/dev/null | head", "description": "Find where start route lives"}
server/http/routes/cloakbox.ts
server/http/routes/store.ts
server/http/routes/accounts-mint.ts
server/store/engine.ts
server/store/profiles.ts
server/http/index.mjs
---routes---
Bash{"command": "grep -n \"start\\|engine.start\\|view_url\\|goto\\|login_ok\\|cookies_injected\\|StartProfileResponse\" server/http/routes/store.ts | head -40", "description": "Find start route in store.ts"}
30:  StartProfileResponse, StopProfileResponse, DeleteProfileResponse, ViewProfileResponse,
47:import { startSchemaEngineRun, schemaEngineStatus } from "../../store/schema-engine.ts";
154:// "Load account philip" = resolve slug→profile_id, then POST /profiles/:id/start.
186:app.post("/profiles/:id/start", async (c) => {
187:  // `goto` = where to land after the engine injects the banked cookies. Without it the
190:  const body = await c.req.json().catch(() => ({} as { goto?: string }));
191:  const goto = typeof body?.goto === "string" && body.goto ? body.goto : "https://www.facebook.com/";
192:  const r = await engine.start(c.req.param("id"), { goto });
193:  return c.json(StartProfileResponse.parse({ ok: true, ...r }));
211:  return c.json(ViewProfileResponse.parse({ ok: true, view_url: r.view_url }));
215:// from the engine's /api/list (open run + per-profile noVNC view_url; the
253:  return c.json(StartSchemaEngineResponse.parse({ ok: true, ...startSchemaEngineRun() }));
314:// the pure executor) persists that pin onto a `workflow_run` row via workflowRuns.start/
333:  const runId = await workflowRuns.start(w);
Bash{"command": "cd ui && grep -rn \"socket\\|e2ee\\|messenger\\|connectMessenger\" src/api.ts src/api/*.ts | head -30", "description": "Find messenger/socket status api"}
ugrep: warning: src/api.ts: No such file or directory
src/api/catalog.ts:7:  site: string;        // facebook | messenger — which adapter tree
src/api/har-traces.ts:91:  socketCount: number; frameTotal: number;
src/api/har-traces.ts:92:  byTransport: Record<string, number>; methods: Record<string, number>; sockets: WsSocket[];
src/api/har-traces.ts:105:  return d.socketCount ? d : null;
src/api/db-types.gen.ts:281:  is_messenger_user: boolean | null;
src/api/db-types.gen.ts:615:  is_e2ee: boolean | null;
src/api/jobs.ts:294:  // accepts then hangs (mid-deploy half-open socket) leaves the fetch unsettled, so
src/api/jobs.ts:348:// Live whatsmeow socket status per account — is the encrypted-messaging socket
src/api/jobs.ts:350:// any last error. Proxied by the backend off the bluebut-e2ee daemon's /accounts.
src/api/jobs.ts:355:  // When the current unbroken whatsmeow socket came up (daemon-tracked). Uptime =
src/api/jobs.ts:356:  // now − connected_since. null when the socket is down (or a pre-uptime daemon).
src/api/jobs.ts:361:  // Stored CAT (bluebut/e2ee-cat/<slug>): fresh = younger than the 20h reuse window
src/api/jobs.ts:371:  const r = await fetch(`${API}/messenger/socket-status`, { headers: bridgeAuth(), signal: AbortSignal.timeout(12_000) });
src/api/jobs.ts:372:  if (!r.ok) throw new Error(`/messenger/socket-status HTTP ${r.status}`);
src/api/jobs.ts:376:// the check that catches a half-dead socket (connected but can't send). `peer`
src/api/jobs.ts:393:  const r = await fetch(`${API}/messenger/health?${qs.toString()}`, { headers: bridgeAuth(), signal: AbortSignal.timeout(25_000) });
src/api/jobs.ts:395:  if (!r.ok && !("alive" in d)) throw new Error(d.error || `/messenger/health HTTP ${r.status}`);
src/api/jobs.ts:398:// Create/(re)connect ONE account's encrypted-messaging (whatsmeow) socket, BROWSER-FREE
src/api/jobs.ts:399:// via the stored CAT — the "Create E2E socket" operator verb. Triggers the e2ee-keepalive
src/api/jobs.ts:401:// and the socket comes up on the next socket-status poll. Never a burn signal (inv #5).
src/api/jobs.ts:402:export async function connectMessenger(account: string): Promise<{ ok: boolean; triggered?: boolean; error?: string }> {
src/api/jobs.ts:403:  const r = await fetch(`${API}/messenger/connect`, {
src/api/jobs.ts:408:  if (!r.ok) throw new Error(d?.error || `/messenger/connect HTTP ${r.status}`);
src/api/entities.ts:27:  e2ee?: boolean;  // a bluebut-e2ee device is registered for this account (can decrypt/send type-15 encrypted Messenger). false/unset = not set up.
src/api/entities.ts:28:  cat_present?: boolean;  // a CAT ignition ticket is stored (keyvault bluebut/e2ee-cat/<slug>) — needed to open the encrypted socket.
src/api/entities.ts:246:// Messenger box — mirrors the messenger tables in db/schema.ts. A thread is
src/api/entities.ts:277:  is_e2ee: boolean | null;           // encrypted — decrypted by the bluebut-e2ee daemon
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/jobs.ts", "offset": 346, "limit": 70}
346	};
347	
348	// Live whatsmeow socket status per account — is the encrypted-messaging socket
349	// connected right now (receiving in realtime), when did it last see an event, and
350	// any last error. Proxied by the backend off the bluebut-e2ee daemon's /accounts.
351	export interface SocketStatusAccount {
352	  slug: string;
353	  connected: boolean;
354	  fbid: number | string | null;
355	  // When the current unbroken whatsmeow socket came up (daemon-tracked). Uptime =
356	  // now − connected_since. null when the socket is down (or a pre-uptime daemon).
357	  connected_since: string | null;
358	  last_event_at: string | null;
359	  last_error: string | null;
360	  wa_device_id: number | null;
361	  // Stored CAT (bluebut/e2ee-cat/<slug>): fresh = younger than the 20h reuse window
362	  // → the daemon can reconnect this account browser-free. null = no CAT ever stored.
363	  cat: { minted_at: string | null; age_ms: number | null; fresh: boolean } | null;
364	}
365	export interface SocketStatus {
366	  accounts: SocketStatusAccount[];
367	  connected: number;
368	  total: number;
369	}
370	export async function fetchSocketStatus(): Promise<SocketStatus> {
371	  const r = await fetch(`${API}/messenger/socket-status`, { headers: bridgeAuth(), signal: AbortSignal.timeout(12_000) });
372	  if (!r.ok) throw new Error(`/messenger/socket-status HTTP ${r.status}`);
373	  return r.json();
374	}
375	// TRUE 100% liveness — a usync round-trip on the daemon (no message, no browser),
376	// the check that catches a half-dead socket (connected but can't send). `peer`
377	// (a numeric fbid) also verifies that recipient is reachable on encrypted chat.
378	export interface MessengerHealth {
379	  ok: boolean;
380	  slug?: string;
381	  fbid?: number | string | null;
382	  alive: boolean;                 // usync round-tripped → send+receive genuinely work
383	  devices?: number;               // our own device count returned by the usync
384	  reason?: string;                // why alive=false (e.g. "usync failed: … timeout")
385	  peer_reachable?: boolean;       // present only when peer= was passed
386	  peer_devices?: number;
387	  peer_reason?: string;
388	  error?: string;
389	}
390	export async function verifyMessenger(account: string, peer?: string): Promise<MessengerHealth> {
391	  const qs = new URLSearchParams({ account });
392	  if (peer) qs.set("peer", peer);
393	  const r = await fetch(`${API}/messenger/health?${qs.toString()}`, { headers: bridgeAuth(), signal: AbortSignal.timeout(25_000) });
394	  const d = await r.json().catch(() => ({}));
395	  if (!r.ok && !("alive" in d)) throw new Error(d.error || `/messenger/health HTTP ${r.status}`);
396	  return d as MessengerHealth;
397	}
398	// Create/(re)connect ONE account's encrypted-messaging (whatsmeow) socket, BROWSER-FREE
399	// via the stored CAT — the "Create E2E socket" operator verb. Triggers the e2ee-keepalive
400	// adapter scoped to this account (--account <slug> --force); the job shows in the Jobs UI
401	// and the socket comes up on the next socket-status poll. Never a burn signal (inv #5).
402	export async function connectMessenger(account: string): Promise<{ ok: boolean; triggered?: boolean; error?: string }> {
403	  const r = await fetch(`${API}/messenger/connect`, {
404	    method: "POST", headers: { ...bridgeAuth(), "Content-Type": "application/json" },
405	    body: JSON.stringify({ account }), signal: AbortSignal.timeout(15_000),
406	  });
407	  const d = await r.json().catch(() => ({}));
408	  if (!r.ok) throw new Error(d?.error || `/messenger/connect HTTP ${r.status}`);
409	  return d as { ok: boolean; triggered?: boolean };
410	}
411	
412	// Operator stop — kill a running/queued bridge job by id. The bridge SIGKILLs
413	// the spawned runner/agent child (or dequeues it if it hasn't started), which
414	// terminates the FB automation mid-flight. Used by the Stop buttons in the jobs
415	// dock + Jobs page.
Bash{"command": "cd ui && grep -rn \"results\\[\" src/pages/Accounts/*.tsx | head; echo \"---status vals---\"; grep -rn \"\\.status ===\\|status ==\\|'loaded'\\|\\\"loaded\\\"\\|status:\" src/pages/Accounts/*.tsx | grep -i \"loaded\\|error\\|status ==\" | head -20", "description": "Check how result status is rendered"}
/bin/bash: line 1: cd: ui: No such file or directory
---status vals---
src/pages/Accounts/AccountDetail.tsx:21:type ResultRow = { status: string; ip: string; country: string; display_name?: string; account_type?: string; error?: string; egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null };
Bash{"command": "grep -rn \"results\\b\\|\\.status\" src/pages/Accounts/AccountDetail.tsx src/pages/Accounts/AccountRow.tsx 2>/dev/null | grep -i \"result\\|status\" | head -25", "description": "Check result status render"}
src/pages/Accounts/AccountDetail.tsx:27:  results: Record<string, ResultRow>; groupCounts: Record<string, { total: number; admin: number }>;
src/pages/Accounts/AccountDetail.tsx:52:    sessions, pageSessions, byManager, account, results, groupCounts, accountInfo, proxyHostOwner,
src/pages/Accounts/AccountDetail.tsx:349:        res={results[acct]}
Bash{"command": "grep -rn \"res\\b\\|res\\.\\|res?\\.\" src/pages/Accounts/AccountDetail.tsx | grep -i \"res\" | sed -n '1,30p'; echo \"===grep component using res===\"; grep -rln \"res:.*ResultRow\\|res?: ResultRow\\|res: ResultRow\" src/pages/Accounts/*.tsx", "description": "Find res consumer"}
src/pages/Accounts/AccountDetail.tsx:349:        res={results[acct]}
===grep component using res===
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/AccountDetail.tsx", "offset": 335, "limit": 25}
335	            <span style={{ fontSize: 11, color: "var(--color-faint)" }}>checks proxy → mints a device if needed → spawns Chrome.</span>
336	          </div>
337	        )}
338	      </div>
339	    )}
340	    {isBurned && (
341	      <Alert type="error">
342	        <strong>FB has this account walled{wall ? ` (${wall})` : ""}.</strong> {sess.checkpoint_type ? "A hard identity wall — this account can't run and can't be recovered by automation." : "Dead until a human clears the wall."} The Cloakbox verdict + Load below are unusable while burned.
343	      </Alert>
344	    )}
345	    <div style={{ marginTop: 14 }}>
346	      <AccountCard
347	        acct={acct}
348	        sess={sess}
349	        res={results[acct]}
350	        groups={groupCounts[acct] ?? { total: 0, admin: 0 }}
351	        isActive={account === acct}
352	        loading={loadingAcct === acct}
353	        // Load = the ONE guided verb: proxy? (add-proxy window) → device?
354	        // (mint inline) → load-session + Control. Always runnable.
355	        onConnect={() => smartLoad(acct)}
356	        onRefresh={() => refreshAcct(acct)}
357	        refreshing={refreshingAcct === acct}
358	        onTypeLogin={() => typeLoginInto(acct)}
359	        typingLogin={typingAcct === acct}
Bash{"command": "grep -n \"res\\b\\|res\\.\\|res?\\.\" src/pages/Accounts/AccountCard.tsx | head -30", "description": "Find AccountCard res usage"}
ugrep: warning: src/pages/Accounts/AccountCard.tsx: No such file or directory
Bash{"command": "grep -rln \"AccountCard\" src/ ; echo \"---\"; f=$(grep -rln \"function AccountCard\\|const AccountCard\\|export.*AccountCard\" src/); echo \"file=$f\"; grep -n \"res\" $f | head -30", "description": "Locate AccountCard and res usage"}
src/components/AccountCard.tsx
src/components/StatusChip.tsx
src/components/AccountSwitcher.tsx
src/components/ProxyPanel/helpers.tsx
src/components/ui/copy.tsx
src/components/AccountCard/parts.tsx
src/components/ui/fields.tsx
src/pages/Jobs.tsx
src/pages/Accounts.tsx
src/pages/Accounts/AccountDetail.tsx
---
file=src/components/AccountCard.tsx
23:  egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null;
24:  rotated?: string[]; // auth cookies FB renewed on the probe → jar refreshed, not just checked
30:  res?: LiveResult;
44:  // Refresh = the headless cookie probe for THIS account: replays the jar
45:  // against facebook.com through the account's own proxy (real egress IP) with
47:  onRefresh: () => void;
48:  refreshing?: boolean;
55:  // Auto login = the FULL flow on the live window: fill → press Login → enter a
68:  // Proxy is MANDATORY to connect: FB sessions must egress through the account's
80:  /** Live whatsmeow (encrypted Messenger) socket + stored-CAT freshness. NON-stale
81:   *  reality: connected NOW, when it last saw an event, CAT fresh (browser-free reconnect). */
96:// Cookie-probe (Refresh) verdict → headline. These describe the STORED COOKIES,
106:  checkpoint:   { label: "Checkpoint — resting", tone: "warn", hint: "recoverable FB challenge — let it REST ~24h, don't run jobs; self-clears on the next login" },
123:  const { acct, sess, res, groups, isActive } = p;
125:  //  • cookie probe (Refresh, `res`): are the STORED cookies valid? Replayed
127:  //    box browser is logged in — Refresh never injects them; Connect does.
130:  const probe = res ?? null;                    // headless cookie check
139:  // A fresh probe makes the headline speak about COOKIES (not a browser-login
144:  const freshness = probe
146:      // Refresh actually RENEWED the jar — FB rotated these auth cookies and we
147:      // persisted the fresh ones (the next replay carries the new xs).
148:      ? { label: `jar refreshed · ${probe.rotated.join(", ")}`, tone: "ok" as const }
159:    : boxStatus === "error" ? (res?.error ?? "check Jobs panel")
164:  // freshness). Feeding it the SAME inputs the table uses (a fresh probe wins
172:    const at = res?.account_type ?? sess?.account_type;
179:  // Presence from cloak_accounts (has_password/has_totp) is the source of truth —
220:            the EXACT evidence behind it (raw box/jar signals + freshness) so nothing
248:            <span className={`rounded px-1.5 py-px text-micro font-semibold ${TONE[freshness.tone]}`}>{freshness.label}</span>
276:                title="last time FB itself confirmed this jar logged_in (FB's own Date header) — the freshness behind the verdict">
279:              {p.intel?.session_expires_in_days != null && (
Bash{"command": "grep -n \"boxStatus\\|loaded\\|res?.status\\|res.status\\|status ===\" src/components/AccountCard.tsx | head -30", "description": "boxStatus mapping"}
131:  const boxStatus = sess?.last_status ?? null;  // last browser-side status
132:  const status = boxStatus ?? probe?.status;    // for StatusDot / fbSays downstream
133:  const authed = boxStatus === "logged_in";
135:  const lastTs = sess?.last_loaded_at ? new Date(sess.last_loaded_at).getTime() : null;
143:    : (VERDICT[boxStatus ?? ""] ?? { label: "Unknown", tone: "muted" as const, hint: "Connect to check" });
149:      : { label: "cookies checked now", tone: probe.status === "logged_in" ? "ok" as const : "warn" as const }
155:    ? (probe.status === "logged_in"
156:        ? "cookies valid — but NOT loaded in the browser yet. Click Connect to log the box browser in."
158:    : boxStatus === "logged_in" && isStale ? "unverified in the browser — click Connect to confirm"
159:    : boxStatus === "error" ? (res?.error ?? "check Jobs panel")
160:    : (VERDICT[boxStatus ?? ""]?.hint);
167:  const live = probe?.status ?? boxStatus;
230:            {(sess?.kv_status === "deleted" || sess?.kv_status === "suspended") && (
265:              <ReadField label="Box status" mono tone={boxStatus === "logged_in" ? "ok" : boxStatus ? "warn" : undefined}
267:                {boxStatus ?? "never connected"}
292:              {sess?.last_loaded_at && <ReadField label="Last checked">{relTime(sess.last_loaded_at)}</ReadField>}
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 read done \"root cause: connectSession stamps flat status:'loaded' & discards orchestrator login_ok/cookies_injected; no verify (ext/e2ee), no save, no close. Also server StartProfileResponse contract strips those fields.\"; python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 worktree done \"in fix/issue-53 worktree\"", "description": "Report read+worktree done"}
(Bash completed with no output)
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 fix start \"surfacing login_ok/cookies_injected end-to-end + verify-and-finalize workflow\"", "description": "fix start"}
(Bash completed with no output)
Now the contract change to surface the fields the server currently strips:

Jul 24, 03:06:14 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts", "old_string": "export const StartProfileResponse = z.object({\n ok: z.literal(true),\n cdp_url: z.string(),\n view_url: z.string(),\n run_id: z.string().optional(),\n egress_ip: z.string().nullable().optional(),\n});", "new_string": "export const StartProfileResponse = z.object({\n ok: z.literal(true),\n cdp_url: z.string(),\n view_url: z.string(),\n run_id: z.string().optional(),\n profile_id: z.string().optional(),\n egress_ip: z.string().nullable().optional(),\n /** COUNT of cookies the engine injected into the live browser from the banked jar (0 = none). */\n cookies_injected: z.number().nullable().optional(),\n /** Orchestrator's post-inject verdict: is the rest
The file /home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "old_string": "export interface CloakOpenResult { ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string }", "new_string": "export interface CloakOpenResult {\n ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string;\n // Store profile_id of the launched browser \u2014 the handle Load's verify-and-finalize\n // steps (harvester diag / save / stop) key on. Resolved slug\u2192profile_id before start.\n profile_id?: string | null;\n // Orchestrator's post-inject truth \u2014 NOT re-derived by the caller, just surfaced:\n // cookies_injected = COUNT of cookies restored
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "old_string": " const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));\n if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };\n return { ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error };", "new_string": " const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));\n if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };\n return {\n ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error,\n profile_id: (d?.profile_id ?? pid) ?? null,\n cookies_injected: d?.cookies_injected ?? nu
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "grep -rn \"fetchSocketStatus\\|export \\* from\\|jobs\" src/api/index.ts | head", "description": "Check api index exports"}
src/api/index.ts:3:export * from "./_core";
src/api/index.ts:4:export * from "./listings";
src/api/index.ts:5:export * from "./workflow-runs";
src/api/index.ts:6:export * from "./jobs";
src/api/index.ts:7:export * from "./catalog";
src/api/index.ts:8:export * from "./har-traces";
src/api/index.ts:9:export * from "./instances";
src/api/index.ts:10:export * from "./proxy";
src/api/index.ts:11:export * from "./accounts";
src/api/index.ts:12:export * from "./entities";
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts", "old_string": " mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,\n connectMessenger,", "new_string": " mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,\n connectMessenger, fetchSocketStatus,"}
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now rewrite `connectSession` into the verify-and-finalize workflow:

Jul 24, 03:06:44 PM

Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts", "old_string": " // Connect \u2014 a direct cdp_load that pins the live tab into the Control mirror.\n async function connectSession(acct: string) {\n setLoadingAcct(acct);\n setControlling(true);\n try {\n const r = await startProfile(acct, \"https://www.facebook.com/\");\n if (r.ok) {\n if (r.seed) setLoadedSeed({ acct, seed: r.seed });\n setResults(prev => ({ ...prev, [acct]: { status: \"loaded\", ip: \"?\", country: \"?\" } }));\n } else {\n toast.error(`Load failed: ${r.error ?? \"unknown\"}`);\n setResults(prev => ({ ...prev, [acct]: { status: \"error\", ip: \"\u2014\", country: \"\u2014\", error: r.error
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "grep -rn \"warn:\\|warn(\\|succeed:\\|event:\\|fail:\" src/state/jobs.ts src/api/jobs.ts 2>/dev/null | grep -i \"warn\\|succeed\" | head", "description": "Verify job.warn exists"}
src/api/jobs.ts:178:      console.warn(`[cli:stream] ${tag} FAIL ${ms}ms: ${reason}`, { site, cmd, account, duration_ms: ms, ok: false, reason });
Bash{"command": "grep -rn \"warn\\b\\|succeed\\b\\|event\\b\\|fail\\b\" src/state/jobs.ts | grep -i \"warn\\|succeed\\|event\\|fail\" | head -20", "description": "Find job handle methods"}
ugrep: warning: src/state/jobs.ts: No such file or directory
Bash{"command": "f=$(grep -rln \"export function useJobs\\|startJob\" src/state/ src/ | head -1); echo \"$f\"; grep -n \"warn\\|succeed\\|event\\|fail\" \"$f\" | grep -i \"warn:\\|warn =\\|succeed:\\|event:\\|fail:\\|warn(\" | head", "description": "Locate job methods def"}
src/state/jobs.tsx
55:    event: (text: string) => void;
56:    succeed: (summary?: string, extra?: { attrs?: LogAttrs }) => void;
58:    warn: (summary: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => void;
59:    fail: (error: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => void;
179:      event: (text: string) => {
186:      succeed: (summary?: string, extra?: { attrs?: LogAttrs }) => {
192:      warn: (summary: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => {
199:      fail: (error: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => {
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53 && git add -A && git commit -q -F - <<'EOF' && git log --oneline -1\nfeat(accounts): Load is a verify-and-finalize workflow (#53)\n\nClicking Load on /accounts/<slug> was fire-and-forget: connectSession\ncalled startProfile and, on r.ok, stamped a flat status:'loaded',\ndiscarding the login_ok/cookies_injected the orchestrator already\ncomputed. Nothing waited for or recognised a successful load.\n\nLoad now:\n - surfaces login_ok/cookies_injected/profile_id end-to-end\n (StartProfileResponse contract \u2192 store route \u2192 api.startProfile;\n the fields were being stripped at the /api/store seam),\n - recognises success: gates on the engine's login_ok verdict \u2014\n a NOT-logged-in load leaves the browser open
18fb9f40 feat(accounts): Load is a verify-and-finalize workflow (#53)
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 fix done \"workflow implemented across contract+server+ui\" && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 push start && git fetch origin main -q && git merge --no-edit origin/main 2>&1 | tail -3 && git push -u origin fix/issue-53 2>&1 | tail -5", "description": "Merge main and push"}
direct push to main is blocked - open a merge request (glab mr create).
Bash{"command": "git branch --show-current && git status -s | head && git push -u origin fix/issue-53 2>&1 | tail -8", "description": "Push branch alone"}
fix/issue-53
remote: 
remote: To create a merge request for fix/issue-53, visit:        
remote:   https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/new?merge_request%5Bsource_branch%5D=fix%2Fissue-53        
remote: 
To https://gitlab.hostbun.cc/devdashco/bluebut.git
 * [new branch]        fix/issue-53 -> fix/issue-53
branch 'fix/issue-53' set up to track 'origin/fix/issue-53'.
Bash{"command": "glab mr create --repo devdashco/bluebut --source-branch fix/issue-53 --target-branch main --title \"fix: Load is a verify-and-finalize workflow (#53)\" --description \"Closes #53 \u2014 Load on /accounts/<slug> was fire-and-forget (connectSession stamped a flat status:'loaded', discarding orchestrator login_ok/cookies_injected). Load now surfaces login_ok/cookies_injected/profile_id end-to-end (they were stripped at the /api/store contract seam), gates success on the engine's login_ok verdict, verifies the capture extension is connected + brings up the e2ee socket, auto-banks the good session, and closes the browser on success (only after banking). Replaces the three manual buttons from #48 with one job-tracked verb.\" --yes 2>&1 | tail -8", "description": "Create MR"}
Creating merge request for fix/issue-53 into main in devdashco/bluebut

https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/16
Bash{"command": "glab mr merge fix/issue-53 --repo devdashco/bluebut --yes 2>&1 | tail -12", "description": "Merge MR"}
ERROR  
          
  All attempts fail:                                                                                                  
  #1: PUT https://gitlab.hostbun.cc/api/v4/projects/devdashco%2Fbluebut/merge_requests/16/merge: 400 {message: SHA    
  must be provided when merging}.
Bash{"command": "SHA=$(git rev-parse fix/issue-53) && echo \"sha=$SHA\" && glab mr merge 16 --repo devdashco/bluebut --sha \"$SHA\" --yes 2>&1 | tail -12", "description": "Merge with explicit SHA"}
sha=18fb9f406ba59d86daa168b7d8fb07a77fe0eabf
! No pipeline running on fix/issue-53
✓ Merged!
https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/16
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 push done \"MR !16 merged to main\" && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 close start && glab issue note 53 --repo devdashco/bluebut --message \"fixed on fix/issue-53 (MR !16, merged to main). Load is now a verify-and-finalize workflow instead of fire-and-forget: connectSession (ui/src/pages/Accounts/useAccountActions.ts) launches the browser, gates on the orchestrator's login_ok verdict (previously discarded), verifies the capture extension is connected (harvester heartbeat poll), brings up the e2ee messenger socket (browser-free via stored CAT + socket-status poll), auto-banks the good session (profiles.save, cookies+datr), then closes the br
https://gitlab.hostbun.cc/devdashco/bluebut/-/work_items/53#note_569
Bash{"command": "glab issue close 53 --repo devdashco/bluebut 2>&1 | tail -3 && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 close done \"issue #53 closed; MR !16 landed on main\"", "description": "Close issue"}
no result shipped
104 messages · 42 tool calls

Solo run

The lead agent did the work itself — no workers were dispatched.

drag to pan · ⌘/ctrl + scroll to zoom · double-click to fit

100%

Goes into the live fixer's pane (or resumes its session if it has none) — the same path a Telegram reply takes.