demobites 1.25.0 → 1.27.0

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.
@@ -509,11 +509,41 @@ if (arg === "listen") {
509
509
  })();
510
510
  writeJson(LOCK, { pid: process.pid, since: new Date().toISOString(), cwd: process.cwd(), service: SERVICE, repo });
511
511
  const noteState = (extra) => writeJson(STATE, { at: new Date().toISOString(), machine_id: listen.machine_id, name, base, workspace: cfg.workspace?.name ?? null, ...extra });
512
+ // PERSONAS (1.27.0, founder 2026-10-17: "the same domain, but three different users sign in, so three tabs"):
513
+ // a tab's jobs run in the tab's own Chrome profile, seeded once from the machine's profile (so what was
514
+ // signed in stays), with its own ledger. The server names the profile on the job ("t12"); none = the machine's.
515
+ const SKIP_IN_PROFILE = /^(Singleton|lockfile$|.*Cache$|Crashpad$|BrowserMetrics|Safe Browsing|component_crx_cache|extensions_crx_cache)/i;
516
+ const rec = () => path.join(process.cwd(), ".recorder");
517
+ const profileFor = (job) => {
518
+ const key = typeof job?.profile === "string" && /^[a-z0-9_-]{1,40}$/i.test(job.profile) ? job.profile : null;
519
+ if (!key) return {};
520
+ const dir = path.join(rec(), "profiles", key);
521
+ const ledger = path.join(rec(), "sessions", `${key}.json`);
522
+ if (!fs.existsSync(dir)) {
523
+ fs.mkdirSync(dir, { recursive: true });
524
+ const base = path.join(rec(), "profile");
525
+ if (fs.existsSync(base)) { try { fs.cpSync(base, dir, { recursive: true, force: true, filter: (src) => !SKIP_IN_PROFILE.test(path.basename(src)) }); } catch (e) { console.log(` ! profile seed: ${e.message}`); } }
526
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
527
+ if (!fs.existsSync(ledger)) { try { fs.copyFileSync(path.join(rec(), "signins.json"), ledger); } catch { fs.writeFileSync(ledger, JSON.stringify({ origins: {} }, null, 2) + "\n"); } }
528
+ console.log(` · a profile of its own for this tab (${key}), seeded from the machine's`);
529
+ }
530
+ return { RECORDER_PROFILE: dir, RECORDER_LEDGER: ledger };
531
+ };
512
532
  const readiness = () => {
513
- let signins = [];
514
- try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
533
+ let signins = [], whos = {};
534
+ try { const l = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")); signins = Object.keys(l.origins || {}); whos = l.who && typeof l.who === "object" ? l.who : {}; } catch { /* none */ }
535
+ // Every session on this machine, by profile: the machine's own, then each tab's.
536
+ const sessions = [];
537
+ for (const o of signins) sessions.push({ profile: "default", origin: o, who: whos[o] ?? null });
538
+ try {
539
+ const dir = path.join(process.cwd(), ".recorder", "sessions");
540
+ for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
541
+ if (!f.endsWith(".json")) continue;
542
+ try { const l = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); const key = f.slice(0, -5); for (const o of Object.keys(l.origins || {})) sessions.push({ profile: key, origin: o, who: l.who?.[o] ?? null }); } catch { /* skip */ }
543
+ }
544
+ } catch { /* none */ }
515
545
  const repos = Object.entries(readJson(reposFile(process.cwd())) ?? {}).filter(([, v]) => v && typeof v.folder === "string" && fs.existsSync(v.folder)).map(([site, v]) => ({ site, folder: v.folder, repo: v.repo ?? null }));
516
- return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, cwd: process.cwd(), platform: process.platform, repo, repos };
546
+ return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, whos, sessions, cwd: process.cwd(), platform: process.platform, repo, repos };
517
547
  };
518
548
  // The code bound to a job's site (1.22.0): the planner reads there.
519
549
  const codeFor = (url) => { try { const site = siteOf(new URL(url).hostname); const b = (readJson(reposFile(process.cwd())) ?? {})[site]; return b && fs.existsSync(b.folder) ? { site, ...b } : null; } catch { return null; } };
@@ -624,7 +654,7 @@ if (arg === "listen") {
624
654
  // Alive while the person signs in (a window can wait minutes): the heartbeat, or the rail calls this machine offline.
625
655
  const heartbeat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness(), busy: true }); }, 5000);
626
656
  const { spawn } = await import("node:child_process");
627
- const child = spawn(process.execPath, [script, job.url], { cwd: process.cwd(), env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
657
+ const child = spawn(process.execPath, [script, job.url, ...(job.fresh && !isLook && !isOut ? ["--fresh"] : [])], { cwd: process.cwd(), env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...profileFor(job) }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
628
658
  track(child);
629
659
  let verdict = null;
630
660
  let buf = "";
@@ -664,7 +694,7 @@ if (arg === "listen") {
664
694
  await event(job.id, "started", { machine: name });
665
695
  await event(job.id, "ready", { disconnected: true, machine: name });
666
696
  const rec = path.join(process.cwd(), ".recorder");
667
- for (const f of ["profile", "signins.json", "config.json", "listen.json"]) fs.rmSync(path.join(rec, f), { recursive: true, force: true });
697
+ for (const f of ["profile", "profiles", "sessions", "signins.json", "config.json", "listen.json"]) fs.rmSync(path.join(rec, f), { recursive: true, force: true });
668
698
  noteState({ busy: false, job: null, last_error: null, disconnected: true });
669
699
  console.log(` ○ Disconnected. The sign-ins and the key are gone from this machine; the login service is removed.`);
670
700
  if (SERVICE) removeService(); // bootout ends this process; the handlers below clear the lock
@@ -705,7 +735,7 @@ if (arg === "listen") {
705
735
  // during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
706
736
  const bound = job.url ? codeFor(job.url) : null;
707
737
  if (bound) console.log(` Code: ${bound.repo ?? bound.site} (${bound.folder.replace(os.homedir(), "~")})`);
708
- const child = (await import("node:child_process")).spawn(process.execPath, [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...(bound ? { DEMOBITES_CODE_DIR: bound.folder } : {}) }, detached: process.platform !== "win32" });
738
+ const child = (await import("node:child_process")).spawn(process.execPath, [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1", ...(bound ? { DEMOBITES_CODE_DIR: bound.folder } : {}), ...profileFor(job) }, detached: process.platform !== "win32" });
709
739
  track(child);
710
740
  const code = await new Promise((resolve) => child.on("close", resolve));
711
741
  const st = await api({ action: "poll", job_id: job.id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobites",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobites": "launcher/index.mjs"
@@ -20,7 +20,7 @@ const host = target.host;
20
20
  say(`Knocking on ${host}…`);
21
21
 
22
22
  let ledger = {};
23
- try { ledger = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "signins.json"), "utf8")).origins ?? {}; } catch { /* none yet */ }
23
+ try { ledger = JSON.parse(fs.readFileSync(process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json"), "utf8")).origins ?? {}; } catch { /* none yet */ }
24
24
 
25
25
  const { chromium } = await import("playwright");
26
26
  const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
@@ -29,7 +29,9 @@ import path from "node:path";
29
29
  import { postEvent } from "./listen-event.mjs";
30
30
 
31
31
  const argv = process.argv.slice(2);
32
- const ledgerPath = path.resolve(process.cwd(), ".recorder", "signins.json");
32
+ // PERSONAS (1.27.0, founder 2026-10-17: "three different users sign in, three tabs"): a tab has its own profile
33
+ // and its own ledger (RECORDER_PROFILE / RECORDER_LEDGER from the listener); the defaults are the machine's.
34
+ const ledgerPath = process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json");
33
35
  const readLedger = () => { try { return JSON.parse(fs.readFileSync(ledgerPath, "utf8")); } catch { return { origins: {} }; } };
34
36
  if (argv[0] === "--check") {
35
37
  let origin; try { origin = new URL(argv[1]).origin; } catch { console.error("Usage: node scripts/signin.mjs --check <url>"); process.exit(2); }
@@ -41,6 +43,8 @@ const { chromium } = await import("playwright"); // after --check, which needs n
41
43
  const url = argv.find((a) => !a.startsWith("--"));
42
44
  if (!url) { console.error("Usage: node scripts/signin.mjs <login url> [--timeout <ms>] [--probe]"); process.exit(2); }
43
45
  const probe = argv.includes("--probe");
46
+ // --fresh: sign in as a different user — the site's cookies in THIS profile go first, so the wall shows.
47
+ const fresh = argv.includes("--fresh");
44
48
  const timeoutMs = Number(argv[argv.indexOf("--timeout") + 1]) || 10 * 60 * 1000;
45
49
  const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
46
50
 
@@ -60,6 +64,15 @@ try {
60
64
  ctx = await chromium.launchPersistentContext(profileDir, launchOpts);
61
65
  }
62
66
  const page = ctx.pages()[0] ?? (await ctx.newPage());
67
+ if (fresh) {
68
+ try {
69
+ const host = new URL(url).hostname;
70
+ const site = host.split(".").slice(-2).join(".");
71
+ await ctx.clearCookies({ domain: new RegExp(`(^|\\.)${site.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") });
72
+ try { const l = readLedger(); for (const o of Object.keys(l.origins ?? {})) { try { if (new URL(o).hostname.endsWith(site)) { delete l.origins[o]; if (l.who) delete l.who[o]; } } catch { /* skip */ } } fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* fine */ }
73
+ console.log(`· Forgot ${site} in this tab's profile; sign in as the other user.`);
74
+ } catch { /* the wall may still show */ }
75
+ }
63
76
  await page.goto(url, { waitUntil: "domcontentloaded" });
64
77
  const startPath = new URL(page.url()).pathname;
65
78
 
@@ -101,13 +114,54 @@ const wallCheck = async () => {
101
114
  });
102
115
  } catch { return { wall: true }; }
103
116
  };
117
+ // WHO SIGNED IN (1.26.0, founder 2026-10-17: "three different users sign in, three tabs… get some data about
118
+ // the signed-in email or username"): never a credential, only what the app itself shows once you are in —
119
+ // an email in the page, an account menu's label, the address a Google chooser was told. Best effort; null
120
+ // is fine. It goes to the ledger beside the origin and to the app as `who`.
121
+ let chooserEmail = null;
122
+ const whoAmI = async () => {
123
+ try {
124
+ await page.waitForTimeout(2500);
125
+ const found = await page.evaluate(() => {
126
+ const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i;
127
+ const pick = (s) => { const m = String(s || "").match(EMAIL); return m ? m[0] : null; };
128
+ const attrs = [];
129
+ for (const el of document.querySelectorAll("[aria-label], [title], img[alt], [data-email], [data-user-email]")) {
130
+ for (const a of ["aria-label", "title", "alt", "data-email", "data-user-email"]) { const v = el.getAttribute(a); if (v) attrs.push(v); }
131
+ }
132
+ for (const v of attrs) { const e = pick(v); if (e) return { email: e }; }
133
+ const text = document.body?.innerText || "";
134
+ const e = pick(text);
135
+ if (e) return { email: e };
136
+ for (const v of attrs) if (/account|profile|avatar|signed in as|logged in as|user menu/i.test(v)) { const name = v.replace(/^(open|toggle)\s+/i, "").replace(/\s*(menu|avatar|profile|account)\s*$/i, "").trim(); if (name && name.length <= 60 && !/^(account|profile|avatar|user)$/i.test(name)) return { name }; }
137
+ return null;
138
+ });
139
+ if (found?.email) return found.email.toLowerCase();
140
+ if (chooserEmail) return chooserEmail;
141
+ if (found?.name) return found.name;
142
+ } catch { /* nothing */ }
143
+ return chooserEmail;
144
+ };
145
+ const noteChooser = async (here) => {
146
+ if (!/accounts\.google\.com|login\.microsoftonline\.com|okta\.com|auth0\.com/i.test(here.host)) return;
147
+ try {
148
+ const v = await page.evaluate(() => {
149
+ const el = document.querySelector("[data-identifier], [data-email], input[type=email], #identifierId, input[name=loginfmt]");
150
+ const s = el?.getAttribute?.("data-identifier") || el?.getAttribute?.("data-email") || el?.value || "";
151
+ const m = String(s).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); return m ? m[0] : null;
152
+ });
153
+ if (v) chooserEmail = v.toLowerCase();
154
+ } catch { /* nothing */ }
155
+ };
156
+ const remember = (origin, who) => { try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [origin]: new Date().toISOString() }; if (who) l.who = { ...(l.who ?? {}), [origin]: who }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* the session still lives in the profile */ } };
104
157
  await page.waitForTimeout(4000);
105
158
  const first = await wallCheck();
106
159
  if (!first.wall) {
107
160
  const origin = new URL(page.url()).origin;
108
161
  console.log(`✓ No sign-in needed for ${origin}.`);
109
- try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [origin]: new Date().toISOString() }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* fine */ }
110
- await postEvent("signed_in", { origin, none: true });
162
+ const who = await whoAmI();
163
+ remember(origin, who);
164
+ await postEvent("signed_in", { origin, none: true, ...(who ? { who } : {}) });
111
165
  await ctx.close();
112
166
  process.exit(0);
113
167
  }
@@ -119,10 +173,12 @@ while (Date.now() - started < timeoutMs) {
119
173
  await page.waitForTimeout(1000);
120
174
  let here;
121
175
  try { here = new URL(page.url()); } catch { continue; }
122
- if (here.pathname !== startPath && !/sign-?in|log-?in|auth|sso|oauth|callback/i.test(here.pathname)) {
123
- console.log(`✓ Signed in. The session is saved for ${here.origin}; next time there is no question and no window.`);
124
- await postEvent("signed_in", { origin: here.origin });
125
- try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [here.origin]: new Date().toISOString() }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* the session still lives in the profile */ }
176
+ await noteChooser(here);
177
+ if (here.pathname !== startPath && !/sign-?in|log-?in|auth|sso|oauth|callback/i.test(here.pathname) && !/accounts\.google\.com|login\.microsoftonline\.com|okta\.com|auth0\.com/i.test(here.host)) {
178
+ const who = await whoAmI();
179
+ console.log(`✓ Signed in${who ? ` as ${who}` : ""}. The session is saved for ${here.origin}; next time there is no question and no window.`);
180
+ await postEvent("signed_in", { origin: here.origin, ...(who ? { who } : {}) });
181
+ remember(here.origin, who);
126
182
  await ctx.close();
127
183
  process.exit(0);
128
184
  }
@@ -32,7 +32,7 @@ export const siteOf = (hostname) => {
32
32
  const site = siteOf(target.hostname);
33
33
  const ofSite = (hostname) => { const h = String(hostname).replace(/^\./, "").toLowerCase(); return h === site || h.endsWith("." + site); };
34
34
 
35
- const ledgerPath = path.resolve(process.cwd(), ".recorder", "signins.json");
35
+ const ledgerPath = process.env.RECORDER_LEDGER ? path.resolve(process.env.RECORDER_LEDGER) : path.resolve(process.cwd(), ".recorder", "signins.json");
36
36
  let ledger = { origins: {} };
37
37
  try { ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); } catch { /* none */ }
38
38
  const origins = Object.keys(ledger.origins ?? {}).filter((o) => { try { return ofSite(new URL(o).hostname); } catch { return false; } });