demobite 1.17.0 → 1.18.1

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.
Files changed (2) hide show
  1. package/launcher/index.mjs +208 -18
  2. package/package.json +1 -1
@@ -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,82 @@ 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
+ // the facts come from the last check-in; before the first one, from the key's own config
89
+ const cfgThere = lock?.cwd ? readJson(path.join(lock.cwd, ".recorder", "config.json")) : null;
90
+ const st = { base: cfgThere?.base ?? null, workspace: cfgThere?.workspace?.name ?? null, ...(readJson(STATE) ?? {}) };
91
+ if (live) {
92
+ 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"})`);
93
+ 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}` : ""}`);
94
+ console.log(` working in ${lock.cwd.replace(os.homedir(), "~")}${lock.repo ? ` · code: ${lock.repo}` : ""}`);
95
+ } else {
96
+ console.log(` ○ Not listening.${st?.at ? ` Last check-in ${ago(st.at)}.` : ""}`);
97
+ }
98
+ console.log(` login service: ${serviceInstalled() ? "installed (starts at login, restarts if it dies)" : "not installed"} · run: demobite ${live ? "off" : "on"}`);
99
+ console.log(` log: ${LOG.replace(os.homedir(), "~")}`);
100
+ };
101
+ if (arg === "status" && !process.argv[3]) { printStatus(); process.exit(0); }
102
+ if (arg === "logs") {
103
+ const n = Number(process.argv[3] || 40);
104
+ let text = ""; try { text = fs.readFileSync(LOG, "utf8"); } catch { /* none */ }
105
+ const lines = text.trimEnd().split("\n"); console.log(lines.slice(-n).join("\n") || " (no log yet)"); process.exit(0);
106
+ }
107
+ if (arg === "off") {
108
+ const def = serviceDef();
109
+ if (process.platform === "darwin") spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: "ignore" });
110
+ else if (process.platform === "linux") spawnSync("systemctl", ["--user", "disable", "--now", "demobite.service"], { stdio: "ignore" });
111
+ else spawnSync("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/f"], { stdio: "ignore" });
112
+ fs.rmSync(def.file, { force: true });
113
+ const pid = killListener();
114
+ // the browsers of an interrupted take go with it
115
+ for (const c of readJson(CHILDREN) ?? []) { try { process.kill(process.platform === "win32" ? c : -c, "SIGTERM"); } catch { /* gone */ } }
116
+ fs.rmSync(CHILDREN, { force: true });
117
+ console.log(` ○ Off.${pid ? ` The listener (pid ${pid}) was stopped.` : ""} The login service is removed. Your key stays; \`demobite on\` brings it back.`);
118
+ process.exit(0);
119
+ }
42
120
 
43
121
  console.log(`\ndemobite v${pkg.version} — the DemoBites agentic recorder\n`);
44
122
 
@@ -78,7 +156,7 @@ if (arg === "record") {
78
156
  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
157
  process.exit(1);
80
158
  }
81
- const r = spawnSync("node", [briefsScript, "list", "--slug", slug], {
159
+ const r = spawnSync(process.execPath, [briefsScript, "list", "--slug", slug], {
82
160
  stdio: "inherit",
83
161
  cwd: process.cwd(),
84
162
  env: { ...process.env, DEMOBITE_CLI: "1" },
@@ -136,7 +214,7 @@ const needed = ["playwright", "@ffmpeg-installer/ffmpeg", "@ffprobe-installer/ff
136
214
  );
137
215
  if (needed.length) {
138
216
  console.log(`\n Installing ${needed.join(", ")} (one-time)…`);
139
- const r = spawnSync("npm", ["install", "--prefix", dest, "--silent", "--no-audit", "--no-fund", ...needed], { stdio: "inherit" });
217
+ const r = spawnSync(...npmCmd(["install", "--prefix", dest, "--silent", "--no-audit", "--no-fund", ...needed]), { stdio: "inherit" });
140
218
  if (r.status === 0) ok("Playwright ready");
141
219
  else warn(`Install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder ${needed.join(" ")}`);
142
220
  }
@@ -148,7 +226,7 @@ if (needed.length) {
148
226
  const pw = path.join(dest, "node_modules", "playwright", "cli.js");
149
227
  const marker = path.join(dest, ".pw-ffmpeg-ready");
150
228
  if (!fs.existsSync(marker) && fs.existsSync(pw)) {
151
- const r = spawnSync("node", [pw, "install", "ffmpeg"], { stdio: "inherit", cwd: dest });
229
+ const r = spawnSync(process.execPath, [pw, "install", "ffmpeg"], { stdio: "inherit", cwd: dest });
152
230
  if (r.status === 0) { fs.writeFileSync(marker, new Date().toISOString()); ok("Playwright's video encoder ready"); }
153
231
  else warn(`Playwright's ffmpeg install failed — run: node ${pw} install ffmpeg`);
154
232
  } else if (fs.existsSync(marker)) ok("Playwright's video encoder ready");
@@ -168,7 +246,7 @@ if (!haveChrome) {
168
246
  }
169
247
  {
170
248
  // Verify both tools actually run (compatibility, not presence).
171
- const r = spawnSync("node", [path.join(dest, "scripts", "media-tools.mjs")], { encoding: "utf8" });
249
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "media-tools.mjs")], { encoding: "utf8" });
172
250
  const rows = (r.stdout || "").trim().split("\n").filter(Boolean).map((l) => l.split("\t"));
173
251
  for (const [tool, source, info] of rows) {
174
252
  if (source === "missing") warn(`${tool}: no working build (${info})`);
@@ -178,6 +256,92 @@ if (!haveChrome) {
178
256
  }
179
257
 
180
258
 
259
+ // ── ON: the listener as a login service (founder 2026-10-16) ─────────────────
260
+ // demobite on [--connect <code>] [--base <url>] [--name <machine>] [--here]
261
+ // Pairs the machine (the code from the app's banner, once), then writes the login entry and starts
262
+ // it. The listener works in ~/.demobite/home (its key and sign-ins live there) unless --here: then in
263
+ // this folder, which makes the folder's repo the Code the planner reads. Running it twice is harmless.
264
+ if (arg === "on") {
265
+ const rest = process.argv.slice(3);
266
+ const flag = (f) => rest.includes(f) ? (rest[rest.indexOf(f) + 1] || "") : "";
267
+ const here = rest.includes("--here");
268
+ const cwd = here ? process.cwd() : SERVICE_CWD;
269
+ fs.mkdirSync(cwd, { recursive: true });
270
+ fs.mkdirSync(HOME, { recursive: true });
271
+ const cfgAt = () => readJson(path.join(cwd, ".recorder", "config.json"));
272
+ const base = (flag("--base") || cfgAt()?.base || "https://app.demobites.com").replace(/\/+$/, "");
273
+ const code = flag("--connect");
274
+ if (code || !cfgAt()?.api_key || (flag("--base") && cfgAt()?.base && cfgAt().base.replace(/\/+$/, "") !== base)) {
275
+ console.log("\n Connecting this machine to your DemoBites account…\n");
276
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), base, ...(code ? ["--connect", code] : [])], { stdio: "inherit", cwd });
277
+ if (r.status !== 0) process.exit(r.status ?? 1);
278
+ }
279
+ if (!cfgAt()?.api_key) { warn("Not connected. Run the line from the app again."); process.exit(1); }
280
+ // The service runs a stable copy of this package: the npx cache is not one. The install line puts the
281
+ // package under ~/.demobite/pkg already; from npx, the package is copied there.
282
+ let runFrom = launcherPath;
283
+ if (!runFrom.startsWith(HOME)) {
284
+ const copyTo = path.join(HOME, "pkg", "node_modules", "demobite");
285
+ fs.rmSync(copyTo, { recursive: true, force: true });
286
+ fs.mkdirSync(copyTo, { recursive: true });
287
+ fs.cpSync(pkgRoot, copyTo, { recursive: true, filter: (src) => !src.includes(`${path.sep}node_modules${path.sep}`) && !src.includes(`${path.sep}.git${path.sep}`) });
288
+ runFrom = path.join(copyTo, "launcher", "index.mjs");
289
+ }
290
+ const name = flag("--name");
291
+ const args = [process.execPath, runFrom, "listen", "--service", ...(name ? ["--name", name] : [])];
292
+ const def = serviceDef();
293
+ const pathEnv = [path.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
294
+ // a log that never grows past a few megabytes
295
+ try { if (fs.statSync(LOG).size > 5 * 1024 * 1024) fs.renameSync(LOG, LOG + ".1"); } catch { /* none */ }
296
+ if (process.platform === "darwin") {
297
+ const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;");
298
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
299
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
300
+ <plist version="1.0"><dict>
301
+ <key>Label</key><string>${SERVICE_LABEL}</string>
302
+ <key>ProgramArguments</key><array>${args.map((a) => `<string>${esc(a)}</string>`).join("")}</array>
303
+ <key>WorkingDirectory</key><string>${esc(cwd)}</string>
304
+ <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>
305
+ <key>RunAtLoad</key><true/>
306
+ <key>KeepAlive</key><true/>
307
+ <key>ThrottleInterval</key><integer>10</integer>
308
+ <key>ProcessType</key><string>Background</string>
309
+ <key>StandardOutPath</key><string>${esc(LOG)}</string>
310
+ <key>StandardErrorPath</key><string>${esc(LOG)}</string>
311
+ </dict></plist>
312
+ `;
313
+ fs.mkdirSync(path.dirname(def.file), { recursive: true });
314
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: "ignore" });
315
+ killListener();
316
+ fs.writeFileSync(def.file, plist);
317
+ const r = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid()}`, def.file], { encoding: "utf8" });
318
+ if (r.status !== 0) { warn(`launchctl refused the service: ${(r.stderr || r.stdout || "").trim()}`); process.exit(1); }
319
+ } else if (process.platform === "linux") {
320
+ 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`;
321
+ fs.mkdirSync(path.dirname(def.file), { recursive: true });
322
+ fs.writeFileSync(def.file, unit);
323
+ killListener();
324
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
325
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", "demobite.service"], { encoding: "utf8" });
326
+ if (r.status !== 0) { warn(`systemd refused the service: ${(r.stderr || "").trim()}`); process.exit(1); }
327
+ } else {
328
+ // Windows: a Run entry that starts the listener hidden at logon (no console window), via a tiny script.
329
+ const q = (t) => `""${t}""`;
330
+ 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`;
331
+ fs.writeFileSync(def.file, vbs);
332
+ spawnSync("reg", ["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/t", "REG_SZ", "/d", `wscript.exe "${def.file}"`, "/f"], { stdio: "ignore" });
333
+ killListener();
334
+ spawnSync("wscript.exe", [def.file], { stdio: "ignore" });
335
+ }
336
+ // a moment for the first check-in, then the truth
337
+ const until = Date.now() + 8000;
338
+ while (Date.now() < until) { const l = readJson(LOCK); if (l?.pid && pidAlive(l.pid) && l.service) break; spawnSync(process.execPath, ["-e", "setTimeout(()=>{},400)"]); }
339
+ console.log("");
340
+ printStatus();
341
+ console.log("\n It starts with the machine from now on. No window to keep open.\n");
342
+ process.exit(0);
343
+ }
344
+
181
345
  // ── LISTENING (founder 2026-10-14 night, "YES!!!!"): `npx demobite listen`.
182
346
  // The machine checks in to the app with the person's recorder key, pulls
183
347
  // the jobs the Capture tab created for this owner, runs the person's own
@@ -194,11 +358,29 @@ if (arg === "listen") {
194
358
  const connectCode = argvRest.includes("--connect") ? (argvRest[argvRest.indexOf("--connect") + 1] || "") : "";
195
359
  if (connectCode || !cfg?.api_key || (argvRest.includes("--base") && cfg?.base && cfg.base.replace(/\/+$/, "") !== base)) {
196
360
  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() });
361
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), base, ...(connectCode ? ["--connect", connectCode] : [])], { stdio: "inherit", cwd: process.cwd() });
198
362
  if (r.status !== 0) process.exit(r.status ?? 1);
199
363
  cfg = readCfg();
200
364
  }
201
365
  if (!cfg?.api_key) { warn("Not connected — run: npx demobite login"); process.exit(1); }
366
+ // ONE LISTENER (founder 2026-10-16: "it can never run twice"): a lock with a live pid. A second start,
367
+ // from a Terminal or a double-pasted line, says who is listening and leaves.
368
+ fs.mkdirSync(HOME, { recursive: true });
369
+ {
370
+ const cur = readJson(LOCK);
371
+ if (cur?.pid && cur.pid !== process.pid && pidAlive(cur.pid)) {
372
+ 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`);
373
+ process.exit(0);
374
+ }
375
+ // the browsers of a listener that died hard
376
+ for (const c of readJson(CHILDREN) ?? []) { try { process.kill(process.platform === "win32" ? c : -c, "SIGKILL"); } catch { /* gone */ } }
377
+ fs.rmSync(CHILDREN, { force: true });
378
+ }
379
+ process.title = "demobite";
380
+ const children = new Set();
381
+ const track = (child) => { if (!child?.pid) return; children.add(child.pid); writeJson(CHILDREN, [...children]); child.on("close", () => { children.delete(child.pid); writeJson(CHILDREN, [...children]); }); };
382
+ 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); };
383
+ process.on("SIGTERM", () => bye("SIGTERM")); process.on("SIGINT", () => bye("SIGINT")); process.on("SIGHUP", () => bye("SIGHUP"));
202
384
  const listenFile = path.join(process.cwd(), ".recorder", "listen.json");
203
385
  let listen = {};
204
386
  try { listen = JSON.parse(fs.readFileSync(listenFile, "utf8")); } catch { /* first time */ }
@@ -241,6 +423,8 @@ if (arg === "listen") {
241
423
  const m = r.stdout.trim().match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
242
424
  return m ? m[1] : null;
243
425
  })();
426
+ writeJson(LOCK, { pid: process.pid, since: new Date().toISOString(), cwd: process.cwd(), service: SERVICE, repo });
427
+ const noteState = (extra) => writeJson(STATE, { at: new Date().toISOString(), machine_id: listen.machine_id, name, base, workspace: cfg.workspace?.name ?? null, ...extra });
244
428
  const readiness = () => {
245
429
  let signins = [];
246
430
  try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
@@ -249,9 +433,10 @@ if (arg === "listen") {
249
433
  const api = async (body) => {
250
434
  try {
251
435
  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); }
436
+ 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); }
437
+ 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
438
  return res.ok ? await res.json() : null;
254
- } catch { return null; }
439
+ } catch (e) { if (body.action === "checkin") noteState({ busy: !!body.busy, last_error: String(e?.message ?? e).slice(0, 120) }); return null; }
255
440
  };
256
441
  const event = (job_id, kind, payload) => api({ action: "event", job_id, kind, payload });
257
442
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -259,8 +444,9 @@ if (arg === "listen") {
259
444
  console.log(`\n Listening as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
260
445
  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
446
  // 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(`
447
+ // window is the machine's side of every recording. The login service has no window: one line instead.
448
+ if (SERVICE) console.log(` ${new Date().toISOString()} listening (login service, pid ${process.pid})`);
449
+ else console.log(`
264
450
  ┌──────────────────────────────────────────────────────────┐
265
451
  │ │
266
452
  │ LEAVE THIS WINDOW OPEN │
@@ -274,7 +460,7 @@ if (arg === "listen") {
274
460
  // A line that visibly lives (founder: "look at the terminal, it's stuck"):
275
461
  // a heartbeat with a clock while we wait, cleared while a job prints.
276
462
  const startedAt = Date.now();
277
- const tty = !!process.stdout.isTTY;
463
+ const tty = !!process.stdout.isTTY && !SERVICE;
278
464
  let ticker = null;
279
465
  const tick = () => {
280
466
  const s = Math.floor((Date.now() - startedAt) / 1000);
@@ -292,9 +478,11 @@ if (arg === "listen") {
292
478
  const isLook = job.kind === "look";
293
479
  const script = path.join(dest, "scripts", isLook ? "look.mjs" : "signin.mjs");
294
480
  console.log(`\n ${isLook ? "◉ Looking at" : "⚿ Sign-in at"} ${job.url}`);
481
+ noteState({ busy: true, job: job.id });
295
482
  await event(job.id, "started", { machine: name });
296
483
  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"] });
484
+ 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" });
485
+ track(child);
298
486
  let verdict = null;
299
487
  let buf = "";
300
488
  child.stdout.on("data", (d) => {
@@ -325,6 +513,7 @@ if (arg === "listen") {
325
513
  const model = typeof job.model === "string" && job.model ? job.model : JOB_MODEL;
326
514
  // Alive while filming (1.13.1): a heartbeat every 5 s so the rail never shows this machine
327
515
  // offline mid-take (the gap after the last step used to do exactly that).
516
+ noteState({ busy: true, job: job.id });
328
517
  const heartbeat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness(), busy: true }); }, 5000);
329
518
  console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""} (${engine === "claude" ? "Claude Code" : "DemoBites agent"} on ${model})`);
330
519
  if (engine !== "claude") {
@@ -335,7 +524,8 @@ if (arg === "listen") {
335
524
  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
525
  // Spawned async (1.14.1): a spawnSync here blocked the event loop, so the 5 s heartbeat never fired
337
526
  // 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" } });
527
+ 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" });
528
+ track(child);
339
529
  const code = await new Promise((resolve) => child.on("close", resolve));
340
530
  const st = await api({ action: "poll", job_id: job.id });
341
531
  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 +588,7 @@ if (arg === "login" || arg === "logout") {
398
588
  // `login <base>` forwards the base (dev, a preview) to login.mjs, which
399
589
  // resolves it before config.base and the production default. Logout
400
590
  // 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))], {
591
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : process.argv.slice(3))], {
402
592
  stdio: "inherit",
403
593
  cwd: process.cwd(),
404
594
  });
@@ -440,12 +630,12 @@ if (arg === "retake") {
440
630
  let cfg = readCfg();
441
631
  if (!cfg?.api_key) {
442
632
  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() });
633
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
444
634
  if (r.status !== 0) process.exit(r.status ?? 1);
445
635
  cfg = readCfg();
446
636
  }
447
637
  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() });
638
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "retake.mjs"), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
449
639
  process.exit(r.status ?? 1);
450
640
  }
451
641
 
@@ -456,12 +646,12 @@ if (arg === "briefs" || arg === "status" || arg === "rules") {
456
646
  let cfg = readCfg();
457
647
  if (!cfg?.api_key) {
458
648
  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() });
649
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
460
650
  if (r.status !== 0) process.exit(r.status ?? 1);
461
651
  cfg = readCfg();
462
652
  }
463
653
  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() });
654
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", `${arg}.mjs`), ...process.argv.slice(3)], { stdio: "inherit", cwd: process.cwd() });
465
655
  process.exit(r.status ?? 1);
466
656
  }
467
657
 
@@ -469,7 +659,7 @@ if (arg === "mcp") {
469
659
  let cfg = readCfg();
470
660
  if (!cfg?.api_key) {
471
661
  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() });
662
+ const r = spawnSync(process.execPath, [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
473
663
  if (r.status !== 0) process.exit(r.status ?? 1);
474
664
  cfg = readCfg();
475
665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
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"