demobite 1.16.0 → 1.18.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.
@@ -5,6 +5,8 @@
5
5
  // npx demobite@latest login connect this machine to DemoBites
6
6
  // npx demobite@latest mcp register the DemoBites management MCP
7
7
  // npx demobite@latest logout disconnect (revokes the key server-side)
8
+ // demobite on [--connect <code>] the listener as a LOGIN SERVICE (1.18.0): wakes with the machine, never twice
9
+ // demobite off | status | logs the service's switch and its truth
8
10
  // npx demobite record <code> UPDATE RADAR (1.6.0): list a workflow's approved briefs for the agent
9
11
  // (1.7.0: the agent then refines, asks once, gets one yes, films in parallel)
10
12
  //
@@ -39,6 +41,80 @@ const readCfg = () => {
39
41
  try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
40
42
  catch { return null; }
41
43
  };
44
+ // npm next to the Node that runs us (the private runtime the install line puts under ~/.demobite), else the PATH's.
45
+ const npmCmd = (args) => {
46
+ const here = path.dirname(process.execPath);
47
+ for (const cli of [path.join(here, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), path.join(here, "node_modules", "npm", "bin", "npm-cli.js")]) {
48
+ if (fs.existsSync(cli)) return [process.execPath, [cli, ...args]];
49
+ }
50
+ return [process.platform === "win32" ? "npm.cmd" : "npm", args];
51
+ };
52
+
53
+ // ── THE SERVICE (founder 2026-10-16: "just like Claude Code… demobite status") ──────────────────
54
+ // `demobite on` installs the listener as a login service: it wakes with the machine, restarts when it
55
+ // dies, and needs no Terminal window. One instance ever (a lock with a live pid); `off` removes it;
56
+ // `status` and `logs` tell the truth. Its home is ~/.demobite: the lock, the log, the state, and the
57
+ // working folder the listener runs in (so .recorder/config.json lives in one known place).
58
+ const HOME = process.env.DEMOBITE_HOME || path.join(os.homedir(), ".demobite");
59
+ const SERVICE_CWD = path.join(HOME, "home");
60
+ const LOCK = path.join(HOME, "listen.lock");
61
+ const LOG = path.join(HOME, "listen.log");
62
+ const STATE = path.join(HOME, "state.json");
63
+ const CHILDREN = path.join(HOME, "children.json");
64
+ const SERVICE_LABEL = "com.demobites.listen";
65
+ const SERVICE = process.argv.includes("--service");
66
+ const launcherPath = fileURLToPath(import.meta.url);
67
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
68
+ const writeJson = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2) + "\n"); };
69
+ const pidAlive = (pid) => { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
70
+ const ago = (iso) => { const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`; };
71
+ const serviceDef = () => {
72
+ if (process.platform === "darwin") return { file: path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
73
+ if (process.platform === "linux") return { file: path.join(os.homedir(), ".config", "systemd", "user", "demobite.service") };
74
+ return { file: path.join(HOME, "service.vbs") };
75
+ };
76
+ const serviceInstalled = () => {
77
+ if (process.platform === "win32") { const r = spawnSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites"], { encoding: "utf8" }); return r.status === 0; }
78
+ return fs.existsSync(serviceDef().file);
79
+ };
80
+ const killListener = () => {
81
+ const lock = readJson(LOCK);
82
+ if (lock?.pid && pidAlive(lock.pid)) { try { process.kill(lock.pid, "SIGTERM"); } catch { /* gone */ } return lock.pid; }
83
+ return null;
84
+ };
85
+ const printStatus = () => {
86
+ const lock = readJson(LOCK);
87
+ const live = !!(lock?.pid && pidAlive(lock.pid));
88
+ const st = readJson(STATE);
89
+ if (live) {
90
+ console.log(` ● Listening as "${st?.name ?? os.hostname()}" since ${new Date(lock.since).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} · for ${st?.workspace ?? "your workspace"} on ${(st?.base ?? "").replace(/^https?:\/\//, "") || "app.demobites.com"} (pid ${lock.pid}${lock.service ? ", login service" : ", a Terminal window"})`);
91
+ if (st?.at) console.log(` last check-in ${ago(st.at)} · ${st.busy ? `recording: ${st.job ?? ""}` : "idle"}${st.last_error ? ` · last error: ${st.last_error}` : ""}`);
92
+ console.log(` working in ${lock.cwd.replace(os.homedir(), "~")}${lock.repo ? ` · code: ${lock.repo}` : ""}`);
93
+ } else {
94
+ console.log(` ○ Not listening.${st?.at ? ` Last check-in ${ago(st.at)}.` : ""}`);
95
+ }
96
+ console.log(` login service: ${serviceInstalled() ? "installed (starts at login, restarts if it dies)" : "not installed"} · run: demobite ${live ? "off" : "on"}`);
97
+ console.log(` log: ${LOG.replace(os.homedir(), "~")}`);
98
+ };
99
+ if (arg === "status" && !process.argv[3]) { printStatus(); process.exit(0); }
100
+ if (arg === "logs") {
101
+ const n = Number(process.argv[3] || 40);
102
+ let text = ""; try { text = fs.readFileSync(LOG, "utf8"); } catch { /* none */ }
103
+ const lines = text.trimEnd().split("\n"); console.log(lines.slice(-n).join("\n") || " (no log yet)"); process.exit(0);
104
+ }
105
+ if (arg === "off") {
106
+ const def = serviceDef();
107
+ if (process.platform === "darwin") spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: "ignore" });
108
+ else if (process.platform === "linux") spawnSync("systemctl", ["--user", "disable", "--now", "demobite.service"], { stdio: "ignore" });
109
+ else spawnSync("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/f"], { stdio: "ignore" });
110
+ fs.rmSync(def.file, { force: true });
111
+ const pid = killListener();
112
+ // the browsers of an interrupted take go with it
113
+ for (const c of readJson(CHILDREN) ?? []) { try { process.kill(process.platform === "win32" ? c : -c, "SIGTERM"); } catch { /* gone */ } }
114
+ fs.rmSync(CHILDREN, { force: true });
115
+ console.log(` ○ Off.${pid ? ` The listener (pid ${pid}) was stopped.` : ""} The login service is removed. Your key stays; \`demobite on\` brings it back.`);
116
+ process.exit(0);
117
+ }
42
118
 
43
119
  console.log(`\ndemobite v${pkg.version} — the DemoBites agentic recorder\n`);
44
120
 
@@ -78,7 +154,7 @@ if (arg === "record") {
78
154
  console.log("Before we record, this project needs a DemoBites account.\nRun `npx demobite login` (it prints a link to approve in your browser), then run this command again.");
79
155
  process.exit(1);
80
156
  }
81
- const r = spawnSync("node", [briefsScript, "list", "--slug", slug], {
157
+ const r = spawnSync(process.execPath, [briefsScript, "list", "--slug", slug], {
82
158
  stdio: "inherit",
83
159
  cwd: process.cwd(),
84
160
  env: { ...process.env, DEMOBITE_CLI: "1" },
@@ -136,7 +212,7 @@ const needed = ["playwright", "@ffmpeg-installer/ffmpeg", "@ffprobe-installer/ff
136
212
  );
137
213
  if (needed.length) {
138
214
  console.log(`\n Installing ${needed.join(", ")} (one-time)…`);
139
- const r = spawnSync("npm", ["install", "--prefix", dest, "--silent", "--no-audit", "--no-fund", ...needed], { stdio: "inherit" });
215
+ const r = spawnSync(...npmCmd(["install", "--prefix", dest, "--silent", "--no-audit", "--no-fund", ...needed]), { stdio: "inherit" });
140
216
  if (r.status === 0) ok("Playwright ready");
141
217
  else warn(`Install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder ${needed.join(" ")}`);
142
218
  }
@@ -148,7 +224,7 @@ if (needed.length) {
148
224
  const pw = path.join(dest, "node_modules", "playwright", "cli.js");
149
225
  const marker = path.join(dest, ".pw-ffmpeg-ready");
150
226
  if (!fs.existsSync(marker) && fs.existsSync(pw)) {
151
- const r = spawnSync("node", [pw, "install", "ffmpeg"], { stdio: "inherit", cwd: dest });
227
+ const r = spawnSync(process.execPath, [pw, "install", "ffmpeg"], { stdio: "inherit", cwd: dest });
152
228
  if (r.status === 0) { fs.writeFileSync(marker, new Date().toISOString()); ok("Playwright's video encoder ready"); }
153
229
  else warn(`Playwright's ffmpeg install failed — run: node ${pw} install ffmpeg`);
154
230
  } else if (fs.existsSync(marker)) ok("Playwright's video encoder ready");
@@ -168,7 +244,7 @@ if (!haveChrome) {
168
244
  }
169
245
  {
170
246
  // Verify both tools actually run (compatibility, not presence).
171
- const r = spawnSync("node", [path.join(dest, "scripts", "media-tools.mjs")], { encoding: "utf8" });
247
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "media-tools.mjs")], { encoding: "utf8" });
172
248
  const rows = (r.stdout || "").trim().split("\n").filter(Boolean).map((l) => l.split("\t"));
173
249
  for (const [tool, source, info] of rows) {
174
250
  if (source === "missing") warn(`${tool}: no working build (${info})`);
@@ -178,6 +254,92 @@ if (!haveChrome) {
178
254
  }
179
255
 
180
256
 
257
+ // ── ON: the listener as a login service (founder 2026-10-16) ─────────────────
258
+ // demobite on [--connect <code>] [--base <url>] [--name <machine>] [--here]
259
+ // Pairs the machine (the code from the app's banner, once), then writes the login entry and starts
260
+ // it. The listener works in ~/.demobite/home (its key and sign-ins live there) unless --here: then in
261
+ // this folder, which makes the folder's repo the Code the planner reads. Running it twice is harmless.
262
+ if (arg === "on") {
263
+ const rest = process.argv.slice(3);
264
+ const flag = (f) => rest.includes(f) ? (rest[rest.indexOf(f) + 1] || "") : "";
265
+ const here = rest.includes("--here");
266
+ const cwd = here ? process.cwd() : SERVICE_CWD;
267
+ fs.mkdirSync(cwd, { recursive: true });
268
+ fs.mkdirSync(HOME, { recursive: true });
269
+ const cfgAt = () => readJson(path.join(cwd, ".recorder", "config.json"));
270
+ const base = (flag("--base") || cfgAt()?.base || "https://app.demobites.com").replace(/\/+$/, "");
271
+ const code = flag("--connect");
272
+ if (code || !cfgAt()?.api_key || (flag("--base") && cfgAt()?.base && cfgAt().base.replace(/\/+$/, "") !== base)) {
273
+ console.log("\n Connecting this machine to your DemoBites account…\n");
274
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), base, ...(code ? ["--connect", code] : [])], { stdio: "inherit", cwd });
275
+ if (r.status !== 0) process.exit(r.status ?? 1);
276
+ }
277
+ if (!cfgAt()?.api_key) { warn("Not connected. Run the line from the app again."); process.exit(1); }
278
+ // The service runs a stable copy of this package: the npx cache is not one. The install line puts the
279
+ // package under ~/.demobite/pkg already; from npx, the package is copied there.
280
+ let runFrom = launcherPath;
281
+ if (!runFrom.startsWith(HOME)) {
282
+ const copyTo = path.join(HOME, "pkg", "node_modules", "demobite");
283
+ fs.rmSync(copyTo, { recursive: true, force: true });
284
+ fs.mkdirSync(copyTo, { recursive: true });
285
+ fs.cpSync(pkgRoot, copyTo, { recursive: true, filter: (src) => !src.includes(`${path.sep}node_modules${path.sep}`) && !src.includes(`${path.sep}.git${path.sep}`) });
286
+ runFrom = path.join(copyTo, "launcher", "index.mjs");
287
+ }
288
+ const name = flag("--name");
289
+ const args = [process.execPath, runFrom, "listen", "--service", ...(name ? ["--name", name] : [])];
290
+ const def = serviceDef();
291
+ const pathEnv = [path.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
292
+ // a log that never grows past a few megabytes
293
+ try { if (fs.statSync(LOG).size > 5 * 1024 * 1024) fs.renameSync(LOG, LOG + ".1"); } catch { /* none */ }
294
+ if (process.platform === "darwin") {
295
+ const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;");
296
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
297
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
298
+ <plist version="1.0"><dict>
299
+ <key>Label</key><string>${SERVICE_LABEL}</string>
300
+ <key>ProgramArguments</key><array>${args.map((a) => `<string>${esc(a)}</string>`).join("")}</array>
301
+ <key>WorkingDirectory</key><string>${esc(cwd)}</string>
302
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${esc(pathEnv)}</string><key>HOME</key><string>${esc(os.homedir())}</string>${process.env.DEMOBITE_HOME ? `<key>DEMOBITE_HOME</key><string>${esc(HOME)}</string>` : ""}</dict>
303
+ <key>RunAtLoad</key><true/>
304
+ <key>KeepAlive</key><true/>
305
+ <key>ThrottleInterval</key><integer>10</integer>
306
+ <key>ProcessType</key><string>Background</string>
307
+ <key>StandardOutPath</key><string>${esc(LOG)}</string>
308
+ <key>StandardErrorPath</key><string>${esc(LOG)}</string>
309
+ </dict></plist>
310
+ `;
311
+ fs.mkdirSync(path.dirname(def.file), { recursive: true });
312
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: "ignore" });
313
+ killListener();
314
+ fs.writeFileSync(def.file, plist);
315
+ const r = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid()}`, def.file], { encoding: "utf8" });
316
+ if (r.status !== 0) { warn(`launchctl refused the service: ${(r.stderr || r.stdout || "").trim()}`); process.exit(1); }
317
+ } else if (process.platform === "linux") {
318
+ const unit = `[Unit]\nDescription=DemoBites listener\nAfter=network-online.target\n\n[Service]\nExecStart=${args.map((a) => JSON.stringify(a)).join(" ")}\nWorkingDirectory=${cwd}\nEnvironment=PATH=${pathEnv}\n${process.env.DEMOBITE_HOME ? `Environment=DEMOBITE_HOME=${HOME}\n` : ""}Restart=always\nRestartSec=10\nStandardOutput=append:${LOG}\nStandardError=append:${LOG}\n\n[Install]\nWantedBy=default.target\n`;
319
+ fs.mkdirSync(path.dirname(def.file), { recursive: true });
320
+ fs.writeFileSync(def.file, unit);
321
+ killListener();
322
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
323
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", "demobite.service"], { encoding: "utf8" });
324
+ if (r.status !== 0) { warn(`systemd refused the service: ${(r.stderr || "").trim()}`); process.exit(1); }
325
+ } else {
326
+ // Windows: a Run entry that starts the listener hidden at logon (no console window), via a tiny script.
327
+ const q = (t) => `""${t}""`;
328
+ const vbs = `Set sh = CreateObject("WScript.Shell")\nsh.CurrentDirectory = "${cwd}"\nsh.Run "${args.map((a) => q(a)).join(" ")} >> ${q(LOG)} 2>&1", 0, False\n`;
329
+ fs.writeFileSync(def.file, vbs);
330
+ spawnSync("reg", ["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/t", "REG_SZ", "/d", `wscript.exe "${def.file}"`, "/f"], { stdio: "ignore" });
331
+ killListener();
332
+ spawnSync("wscript.exe", [def.file], { stdio: "ignore" });
333
+ }
334
+ // a moment for the first check-in, then the truth
335
+ const until = Date.now() + 8000;
336
+ while (Date.now() < until) { const l = readJson(LOCK); if (l?.pid && pidAlive(l.pid) && l.service) break; spawnSync(process.execPath, ["-e", "setTimeout(()=>{},400)"]); }
337
+ console.log("");
338
+ printStatus();
339
+ console.log("\n It starts with the machine from now on. No window to keep open.\n");
340
+ process.exit(0);
341
+ }
342
+
181
343
  // ── LISTENING (founder 2026-10-14 night, "YES!!!!"): `npx demobite listen`.
182
344
  // The machine checks in to the app with the person's recorder key, pulls
183
345
  // the jobs the Capture tab created for this owner, runs the person's own
@@ -194,11 +356,29 @@ if (arg === "listen") {
194
356
  const connectCode = argvRest.includes("--connect") ? (argvRest[argvRest.indexOf("--connect") + 1] || "") : "";
195
357
  if (connectCode || !cfg?.api_key || (argvRest.includes("--base") && cfg?.base && cfg.base.replace(/\/+$/, "") !== base)) {
196
358
  console.log(`\n Connecting this ${connectCode ? "machine" : "project"} to your DemoBites account first…\n`);
197
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), base, ...(connectCode ? ["--connect", connectCode] : [])], { stdio: "inherit", cwd: process.cwd() });
359
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), base, ...(connectCode ? ["--connect", connectCode] : [])], { stdio: "inherit", cwd: process.cwd() });
198
360
  if (r.status !== 0) process.exit(r.status ?? 1);
199
361
  cfg = readCfg();
200
362
  }
201
363
  if (!cfg?.api_key) { warn("Not connected — run: npx demobite login"); process.exit(1); }
364
+ // ONE LISTENER (founder 2026-10-16: "it can never run twice"): a lock with a live pid. A second start,
365
+ // from a Terminal or a double-pasted line, says who is listening and leaves.
366
+ fs.mkdirSync(HOME, { recursive: true });
367
+ {
368
+ const cur = readJson(LOCK);
369
+ if (cur?.pid && cur.pid !== process.pid && pidAlive(cur.pid)) {
370
+ console.log(` ● Already listening (pid ${cur.pid}, since ${new Date(cur.since).toLocaleTimeString()}${cur.service ? ", the login service" : ""}). Nothing to do.\n demobite status · demobite off`);
371
+ process.exit(0);
372
+ }
373
+ // the browsers of a listener that died hard
374
+ for (const c of readJson(CHILDREN) ?? []) { try { process.kill(process.platform === "win32" ? c : -c, "SIGKILL"); } catch { /* gone */ } }
375
+ fs.rmSync(CHILDREN, { force: true });
376
+ }
377
+ process.title = "demobite";
378
+ const children = new Set();
379
+ const track = (child) => { if (!child?.pid) return; children.add(child.pid); writeJson(CHILDREN, [...children]); child.on("close", () => { children.delete(child.pid); writeJson(CHILDREN, [...children]); }); };
380
+ const bye = (sig) => { for (const c of children) { try { process.kill(process.platform === "win32" ? c : -c, "SIGTERM"); } catch { /* gone */ } } try { if (readJson(LOCK)?.pid === process.pid) fs.rmSync(LOCK, { force: true }); } catch { /* fine */ } fs.rmSync(CHILDREN, { force: true }); process.exit(sig === "SIGINT" ? 130 : 0); };
381
+ process.on("SIGTERM", () => bye("SIGTERM")); process.on("SIGINT", () => bye("SIGINT")); process.on("SIGHUP", () => bye("SIGHUP"));
202
382
  const listenFile = path.join(process.cwd(), ".recorder", "listen.json");
203
383
  let listen = {};
204
384
  try { listen = JSON.parse(fs.readFileSync(listenFile, "utf8")); } catch { /* first time */ }
@@ -241,6 +421,8 @@ if (arg === "listen") {
241
421
  const m = r.stdout.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
242
422
  return m ? m[1] : null;
243
423
  })();
424
+ writeJson(LOCK, { pid: process.pid, since: new Date().toISOString(), cwd: process.cwd(), service: SERVICE, repo });
425
+ const noteState = (extra) => writeJson(STATE, { at: new Date().toISOString(), machine_id: listen.machine_id, name, base, workspace: cfg.workspace?.name ?? null, ...extra });
244
426
  const readiness = () => {
245
427
  let signins = [];
246
428
  try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
@@ -249,9 +431,10 @@ if (arg === "listen") {
249
431
  const api = async (body) => {
250
432
  try {
251
433
  const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.api_key}` }, body: JSON.stringify(body) });
252
- if (res.status === 401) { console.error("\n The recorder key was refused. Run: npx demobite login"); process.exit(1); }
434
+ if (res.status === 401) { console.error("\n The recorder key was refused. Run: demobite login"); noteState({ busy: false, last_error: "the recorder key was refused" }); process.exit(1); }
435
+ if (body.action === "checkin") noteState({ busy: !!body.busy, job: body.busy ? (readJson(STATE)?.job ?? null) : null, last_error: res.ok ? null : `the app answered ${res.status}` });
253
436
  return res.ok ? await res.json() : null;
254
- } catch { return null; }
437
+ } catch (e) { if (body.action === "checkin") noteState({ busy: !!body.busy, last_error: String(e?.message ?? e).slice(0, 120) }); return null; }
255
438
  };
256
439
  const event = (job_id, kind, payload) => api({ action: "event", job_id, kind, payload });
257
440
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -259,8 +442,9 @@ if (arg === "listen") {
259
442
  console.log(`\n Listening as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
260
443
  console.log(` Ask for a demo in the app (Create → Agentic Capture). ${engine === "claude" ? (agentBin ? `Claude Code${agentInfo.agent_version ? " " + agentInfo.agent_version : ""} plans the demos, on your own plan; they film on ${JOB_MODEL} unless you pick another model in the app.` : "! --engine=claude, but Claude Code was not found on this machine.") : `Demos are planned by the DemoBites agent on ${JOB_MODEL} (pick another model in the app)${process.env.DEMOBITES_NO_CODE ? "; code sharing is off" : repo ? "; the files it reads from this project are logged in each take" : ""}.`}`);
261
444
  // The one thing the person must know, boxed (founder 2026-10-15): this
262
- // window is the machine's side of every recording.
263
- console.log(`
445
+ // window is the machine's side of every recording. The login service has no window: one line instead.
446
+ if (SERVICE) console.log(` ${new Date().toISOString()} listening (login service, pid ${process.pid})`);
447
+ else console.log(`
264
448
  ┌──────────────────────────────────────────────────────────┐
265
449
  │ │
266
450
  │ LEAVE THIS WINDOW OPEN │
@@ -274,7 +458,7 @@ if (arg === "listen") {
274
458
  // A line that visibly lives (founder: "look at the terminal, it's stuck"):
275
459
  // a heartbeat with a clock while we wait, cleared while a job prints.
276
460
  const startedAt = Date.now();
277
- const tty = !!process.stdout.isTTY;
461
+ const tty = !!process.stdout.isTTY && !SERVICE;
278
462
  let ticker = null;
279
463
  const tick = () => {
280
464
  const s = Math.floor((Date.now() - startedAt) / 1000);
@@ -292,9 +476,11 @@ if (arg === "listen") {
292
476
  const isLook = job.kind === "look";
293
477
  const script = path.join(dest, "scripts", isLook ? "look.mjs" : "signin.mjs");
294
478
  console.log(`\n ${isLook ? "◉ Looking at" : "⚿ Sign-in at"} ${job.url}`);
479
+ noteState({ busy: true, job: job.id });
295
480
  await event(job.id, "started", { machine: name });
296
481
  const { spawn } = await import("node:child_process");
297
- const child = spawn("node", [script, job.url], { cwd: process.cwd(), env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" }, stdio: ["ignore", "pipe", "pipe"] });
482
+ 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" });
483
+ track(child);
298
484
  let verdict = null;
299
485
  let buf = "";
300
486
  child.stdout.on("data", (d) => {
@@ -325,6 +511,7 @@ if (arg === "listen") {
325
511
  const model = typeof job.model === "string" && job.model ? job.model : JOB_MODEL;
326
512
  // Alive while filming (1.13.1): a heartbeat every 5 s so the rail never shows this machine
327
513
  // offline mid-take (the gap after the last step used to do exactly that).
514
+ noteState({ busy: true, job: job.id });
328
515
  const heartbeat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness(), busy: true }); }, 5000);
329
516
  console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""} (${engine === "claude" ? "Claude Code" : "DemoBites agent"} on ${model})`);
330
517
  if (engine !== "claude") {
@@ -332,10 +519,11 @@ if (arg === "listen") {
332
519
  await event(job.id, "started", { machine: name });
333
520
  const takeDir = path.join(process.cwd(), ".recorder", "takes", job.id);
334
521
  fs.mkdirSync(takeDir, { recursive: true });
335
- fs.writeFileSync(path.join(takeDir, "job.json"), JSON.stringify({ id: job.id, brief: job.brief, url: job.url ?? null, model, review_storyboard: job.review_storyboard !== false }, null, 2) + "\n");
522
+ fs.writeFileSync(path.join(takeDir, "job.json"), JSON.stringify({ id: job.id, brief: job.brief, url: job.url ?? null, model, review_storyboard: job.review_storyboard !== false, ...(job.retake_of ? { retake_of: job.retake_of, retake_note: job.retake_note ?? null, storyboard: job.storyboard ?? null } : {}) }, null, 2) + "\n");
336
523
  // Spawned async (1.14.1): a spawnSync here blocked the event loop, so the 5 s heartbeat never fired
337
524
  // during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
338
- const child = (await import("node:child_process")).spawn("node", [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" } });
525
+ 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" }, detached: process.platform !== "win32" });
526
+ track(child);
339
527
  const code = await new Promise((resolve) => child.on("close", resolve));
340
528
  const st = await api({ action: "poll", job_id: job.id });
341
529
  if (st && !["ready", "failed", "cancelled"].includes(st.status)) await event(job.id, "failed", { message: code === 0 ? "The demo did not come through — try again" : `The take stopped (exit ${code})` });
@@ -398,7 +586,7 @@ if (arg === "login" || arg === "logout") {
398
586
  // `login <base>` forwards the base (dev, a preview) to login.mjs, which
399
587
  // resolves it before config.base and the production default. Logout
400
588
  // forwards nothing extra: the key's own home always wins there.
401
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : process.argv.slice(3))], {
589
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : process.argv.slice(3))], {
402
590
  stdio: "inherit",
403
591
  cwd: process.cwd(),
404
592
  });
@@ -440,12 +628,12 @@ if (arg === "retake") {
440
628
  let cfg = readCfg();
441
629
  if (!cfg?.api_key) {
442
630
  console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
443
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
631
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
444
632
  if (r.status !== 0) process.exit(r.status ?? 1);
445
633
  cfg = readCfg();
446
634
  }
447
635
  if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
448
- const r = spawnSync("node", [path.join(dest, "scripts", "retake.mjs"), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
636
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "retake.mjs"), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
449
637
  process.exit(r.status ?? 1);
450
638
  }
451
639
 
@@ -456,12 +644,12 @@ if (arg === "briefs" || arg === "status" || arg === "rules") {
456
644
  let cfg = readCfg();
457
645
  if (!cfg?.api_key) {
458
646
  console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
459
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
647
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
460
648
  if (r.status !== 0) process.exit(r.status ?? 1);
461
649
  cfg = readCfg();
462
650
  }
463
651
  if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
464
- const r = spawnSync("node", [path.join(dest, "scripts", `${arg}.mjs`), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
652
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", `${arg}.mjs`), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
465
653
  process.exit(r.status ?? 1);
466
654
  }
467
655
 
@@ -469,7 +657,7 @@ if (arg === "mcp") {
469
657
  let cfg = readCfg();
470
658
  if (!cfg?.api_key) {
471
659
  console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
472
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
660
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
473
661
  if (r.status !== 0) process.exit(r.status ?? 1);
474
662
  cfg = readCfg();
475
663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.16.0",
3
+ "version": "1.18.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
  "demobite": "launcher/index.mjs"
@@ -197,7 +197,11 @@ const firstMessage = () => {
197
197
  `## The app's words on the starting screen\n${vocab}`,
198
198
  `## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
199
199
  `## The project's files${codeOn ? " (this machine sits in the app's repository; read_file is on)" : " (no code here)"}\n${listFiles()}`,
200
- codeOn ? `Write the storyboard, probe it, fix it, probe again, then submit_storyboard.` : `There is no code: explore first (explore from the starting address, looking_for = the brief's nouns), then write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
200
+ // A RETAKE (founder 2026-10-15): the same demo again, because the app changed under it. The previous
201
+ // storyboard is the base; the note says what to keep an eye on; the probe finds what moved.
202
+ job.retake_of && job.storyboard
203
+ ? `## This is a retake\nThe person wants the same demo filmed again, the same length and the same shots, because the app changed since. Their note: "${(job.retake_note || "no note").replace(/"/g, "'")}". ${job.storyboard.storyboard ? "Start from the previous storyboard below: keep its steps and its lines; probe it; fix only what no longer resolves or what the note names; then submit_storyboard. Never explore when the probe passes." : "Only the previous storyboard's lines survive (below): rebuild the same steps with the same lines, find the controls again, probe, then submit_storyboard."}\n\n## The previous storyboard\n${JSON.stringify(job.storyboard.storyboard ?? job.storyboard.steps ?? job.storyboard)}`
204
+ : codeOn ? `Write the storyboard, probe it, fix it, probe again, then submit_storyboard.` : `There is no code: explore first (explore from the starting address, looking_for = the brief's nouns), then write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
201
205
  ];
202
206
  return parts.join("\n\n");
203
207
  };
@@ -63,7 +63,8 @@ if (mode === "await") {
63
63
  rows.forEach((r, i) => boxOut.push(`│ ${`${String(i + 1).padStart(2)} ${mmss(r.at)} ${cap2(r.text + (r.pointed ? " — pointed at, not pressed" : ""), W2 - 14)}`.padEnd(W2 - 2)} │`));
64
64
  boxOut.push(`│ ${"".padEnd(W2 - 2)} │`); boxOut.push(`└${"─".repeat(W2)}┘`);
65
65
  if (!listening) { console.log(boxOut.join("\n")); console.log("film"); process.exit(0); }
66
- await postEvent("storyboard", { box: boxOut.join("\n"), total: mmss(total), steps: rows.map((r, i) => ({ n: i + 1, at: mmss(r.at), text: r.text, pointed: r.pointed })) });
66
+ // The whole storyboard rides along (1.17.0): a retake starts from it, selectors and all.
67
+ await postEvent("storyboard", { box: boxOut.join("\n"), total: mmss(total), steps: rows.map((r, i) => ({ n: i + 1, at: mmss(r.at), text: r.text, pointed: r.pointed })), storyboard: sb });
67
68
  const started = Date.now();
68
69
  while (Date.now() - started < 30 * 60 * 1000) {
69
70
  const st = await poll();