retake-demos 0.4.0 → 0.4.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.
@@ -72,6 +72,15 @@ async function waitForHuman(kind, text, detail) {
72
72
  return s.answer ?? "";
73
73
  }
74
74
  }
75
+ /** Tell the window how far into a recording we are. Fire-and-forget: a demo
76
+ must never fail because a progress ping did not land. */
77
+ async function progress(p) {
78
+ if (!UI)
79
+ return;
80
+ const body = JSON.stringify({ progress: { ...p, demo: LAST_DEMO || undefined } });
81
+ const url = SESSION ? `${UI}/api/operator/${SESSION}/log` : `${UI}/api/activity`;
82
+ await fetch(url, { method: "POST", body }).catch(() => { });
83
+ }
75
84
  const text = (s) => ({ content: [{ type: "text", text: s }] });
76
85
  const safe = (s) => /^[a-z0-9-]+$/.test(s);
77
86
  const manifestPath = (name) => path.join(DEMOS, `${name}.yaml`);
@@ -374,46 +383,72 @@ server.registerTool("run", { description: "Record the demo and render it. Slow (
374
383
  await tell(`preview overrides the pinned ${pinned.width}×${pinned.height} viewport`);
375
384
  const outDir = path.join(OUT, name);
376
385
  acquireLock(outDir);
377
- try {
378
- // Moved aside, not deleted: a failed recording must not cost the person
379
- // the take they already had.
380
- stashPrevious(outDir);
381
- await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
382
- let take;
386
+ return await holdOpen(async () => {
383
387
  try {
384
- take = await record(manifest, { outDir, manifestDir: DEMOS, locked: true, until, from, log: () => { } });
388
+ // Moved aside, not deleted: a failed recording must not cost the person
389
+ // the take they already had.
390
+ stashPrevious(outDir);
391
+ // The clock has to measure THIS run. It used to count from whenever the
392
+ // activity feed first went active, which on a long-lived window meant
393
+ // hours: someone watched a four-minute take report 529 minutes.
394
+ const runStart = Date.now();
395
+ void progress({ phase: "recording", step: 0, total: manifest.steps.length, label: "starting", runStart });
396
+ await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
397
+ let take;
398
+ try {
399
+ take = await record(manifest, {
400
+ outDir, manifestDir: DEMOS, locked: true, until, from, log: () => { },
401
+ // Throttled: a 251-step demo would otherwise be 251 posts, and the
402
+ // window only needs to know roughly how far along this is.
403
+ onProgress: (pr) => { if (pr.step === 1 || pr.step === pr.total || pr.step % 2 === 0)
404
+ void progress({ ...pr, runStart }); },
405
+ });
406
+ }
407
+ catch (e) {
408
+ if (restorePrevious(outDir))
409
+ await tell("Recording failed — your previous take has been put back.");
410
+ throw e;
411
+ }
412
+ keepPrevious(outDir, (l) => void tell(l));
413
+ // Render has no per-stage hook, but the last render of THIS demo measured
414
+ // itself into facts.json — so the bar can run on a real number rather
415
+ // than a spinner. It is an estimate and is labelled as one.
416
+ let etaSec = 0;
417
+ try {
418
+ const f = JSON.parse(fs.readFileSync(path.join(outDir, "facts.json"), "utf8"));
419
+ etaSec = Math.round(Object.values(f.timings ?? {}).reduce((a, b) => a + Number(b), 0));
420
+ }
421
+ catch { /* first render of this demo: no estimate to give */ }
422
+ void progress({ phase: "rendering", step: 0, total: 0, label: "rendering", etaSec, runStart });
423
+ await tell(`Rendering…`);
424
+ const a = await render(manifest, take, outDir, {});
425
+ const c = check(outDir, manifest);
426
+ const failed = take.timeline.find((e) => e.ok === false);
427
+ await tell(failed ? `Stopped at step ${failed.index}: ${failed.summary}` : `Take done — all steps ok · ${(take.duration - take.trimBefore).toFixed(0)}s`);
428
+ // A failed take is a finding, not a result: say exactly where and what
429
+ // the page showed, so even a small model gets a one-line repair.
430
+ if (failed)
431
+ return text(`FAILED at step ${failed.index} — ${failed.summary}: ${failed.error ?? "error"}${failed.screen ? `\nOn screen at that moment: “${failed.screen}”` : ""}\nThe camera stopped there (${(take.duration - take.trimBefore).toFixed(0)}s recorded). This is not a demo yet: edit the manifest, dry, run again. Do NOT call done on a failed take.\n\n${summariseTake(take)}`);
432
+ // The gate: a person's eyes are the only check for whether this is the
433
+ // RIGHT video, and they are cheapest to apply to a draft. Say so here,
434
+ // where the agent is deciding what to do next, rather than after the
435
+ // expensive take has already been spent.
436
+ // Measured, not estimated: this draft just performed the same demo against
437
+ // the same app, so its own capture time is what the full take will cost.
438
+ const took = take.captureSec ?? 0;
439
+ const mins = took >= 120 ? `${Math.round(took / 60)} min` : `${Math.round(took)}s`;
440
+ const gate = preview && took >= EXPENSIVE_TAKE_SECONDS && c.ok
441
+ ? `\n\nTHIS DRAFT TOOK ${mins} TO RECORD, and the full take costs about the same again. Do not run it at full quality yet. Show this draft to the person — \`ask\` them, say how long it is and what it shows, and point them at the Retake window. Their answer is the one thing no check here can give you: whether this is the right video. If they cannot be reached, stop and say what is ready and what the full take would cost; do not spend it on their behalf.`
442
+ : "";
443
+ return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}${gate}`);
385
444
  }
386
- catch (e) {
387
- if (restorePrevious(outDir))
388
- await tell("Recording failed — your previous take has been put back.");
389
- throw e;
445
+ finally {
446
+ releaseLock(outDir);
447
+ // The run is over. `done` is a separate tool the agent may never call, so
448
+ // waiting for it left the bar sitting at "nearly there" indefinitely.
449
+ void progress({ phase: "idle", step: 0, total: 0, label: "", clear: true });
390
450
  }
391
- keepPrevious(outDir, (l) => void tell(l));
392
- await tell(`Rendering…`);
393
- const a = await render(manifest, take, outDir, {});
394
- const c = check(outDir, manifest);
395
- const failed = take.timeline.find((e) => e.ok === false);
396
- await tell(failed ? `Stopped at step ${failed.index}: ${failed.summary}` : `Take done — all steps ok · ${(take.duration - take.trimBefore).toFixed(0)}s`);
397
- // A failed take is a finding, not a result: say exactly where and what
398
- // the page showed, so even a small model gets a one-line repair.
399
- if (failed)
400
- return text(`FAILED at step ${failed.index} — ${failed.summary}: ${failed.error ?? "error"}${failed.screen ? `\nOn screen at that moment: “${failed.screen}”` : ""}\nThe camera stopped there (${(take.duration - take.trimBefore).toFixed(0)}s recorded). This is not a demo yet: edit the manifest, dry, run again. Do NOT call done on a failed take.\n\n${summariseTake(take)}`);
401
- // The gate: a person's eyes are the only check for whether this is the
402
- // RIGHT video, and they are cheapest to apply to a draft. Say so here,
403
- // where the agent is deciding what to do next, rather than after the
404
- // expensive take has already been spent.
405
- // Measured, not estimated: this draft just performed the same demo against
406
- // the same app, so its own capture time is what the full take will cost.
407
- const took = take.captureSec ?? 0;
408
- const mins = took >= 120 ? `${Math.round(took / 60)} min` : `${Math.round(took)}s`;
409
- const gate = preview && took >= EXPENSIVE_TAKE_SECONDS && c.ok
410
- ? `\n\nTHIS DRAFT TOOK ${mins} TO RECORD, and the full take costs about the same again. Do not run it at full quality yet. Show this draft to the person — \`ask\` them, say how long it is and what it shows, and point them at the Retake window. Their answer is the one thing no check here can give you: whether this is the right video. If they cannot be reached, stop and say what is ready and what the full take would cost; do not spend it on their behalf.`
411
- : "";
412
- return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}${gate}`);
413
- }
414
- finally {
415
- releaseLock(outDir);
416
- }
451
+ });
417
452
  });
418
453
  server.registerTool("render", { description: "Re-render the last recording with the current manifest. No browser, seconds. THIS, NOT `run`, IS THE ANSWER TO ALMOST EVERY CHANGE: size and shape and format, a caption's words, speed, zoom, cards, music, the poster, and WHERE a caption sits (`nudge: <ms>` on the scene moves its marker, and the still and thumbnail follow). Only a change to what the demo DOES needs the camera again — and even then, `run` with `from`/`until` records one end of it rather than all of it.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
419
454
  LAST_DEMO = name;
@@ -496,11 +531,31 @@ const transport = new StdioServerTransport();
496
531
  // forever — a day of agent sessions left a row of them running, each holding a
497
532
  // Node runtime for a client that had long since exited.
498
533
  let leaving = false;
534
+ /** Work that must not be abandoned halfway. A recording is minutes of real
535
+ time against a real app; dropping it because the agent's socket closed
536
+ throws away the expensive half of the job and leaves nothing behind. */
537
+ let inFlight = 0;
538
+ export function holdOpen(work) {
539
+ inFlight++;
540
+ return work().finally(() => { inFlight--; if (leaving && inFlight === 0)
541
+ process.exit(0); });
542
+ }
499
543
  const leave = () => {
500
544
  if (leaving)
501
545
  return;
502
546
  leaving = true;
503
- void server.close().catch(() => { }).finally(() => process.exit(0));
547
+ // Stop accepting new work immediately, but let a take in progress finish
548
+ // and write its files. Before this, the server exited the moment the
549
+ // client's socket closed and the recording died at whatever step it was
550
+ // on -- the person came back to nothing.
551
+ void server.close().catch(() => { });
552
+ if (inFlight > 0) {
553
+ process.stderr.write(`retake: client disconnected, finishing ${inFlight} job(s) in flight\n`);
554
+ // A take cannot outlive its own ceiling, so this is a backstop, not a policy.
555
+ setTimeout(() => process.exit(0), 65 * 60_000).unref();
556
+ return;
557
+ }
558
+ process.exit(0);
504
559
  };
505
560
  transport.onclose = leave;
506
561
  process.stdin.once("end", leave);
package/dist/record.js CHANGED
@@ -504,6 +504,18 @@ export async function record(m, opts) {
504
504
  entry.camera = await resolveCamera(page, m, q, step, i);
505
505
  }
506
506
  log(`[${String(i).padStart(2, "0")}] ${entry.summary}${entry.camera ? ` · camera ${entry.camera.zoom}× on ${entry.camera.focus}` : ""}`);
507
+ opts.onProgress?.({ phase: "recording", step: i + 1, total: m.steps.length, label: entry.summary });
508
+ // A resume point on disk, so a take that dies is not a take lost. It
509
+ // survives a kill because it is written as it goes, not at the end —
510
+ // and `--from <that scene>` re-records only what came after it.
511
+ if (step.action === "scene" && step.label) {
512
+ try {
513
+ fs.writeFileSync(path.join(opts.outDir, "reached.json"), JSON.stringify({
514
+ demo: m.name, scene: step.label, step: i + 1, of: m.steps.length, at: new Date().toISOString(),
515
+ }));
516
+ }
517
+ catch { /* a resume hint is a courtesy, never a reason to fail */ }
518
+ }
507
519
  try {
508
520
  await runStep(rec, page, step, m, ctx);
509
521
  }
@@ -631,6 +643,12 @@ export async function record(m, opts) {
631
643
  // Timeline offsets are already relative to videoStartedAt (= document commit,
632
644
  // calibrated to within ~0.2s of the first video frame on a warm route). The
633
645
  // real file length is authoritative for the end.
646
+ // The take finished, so there is nothing to resume: leaving the hint would
647
+ // send someone back into the middle of a demo they already have.
648
+ try {
649
+ fs.rmSync(path.join(opts.outDir, "reached.json"), { force: true });
650
+ }
651
+ catch { /* fine */ }
634
652
  const trimBefore = sec(setupEnd - t0);
635
653
  // The recording keeps rolling while the browser is torn down, so the raw
636
654
  // video always has a tail of nothing after the last step. The take ends
package/dist/ui/chat.html CHANGED
@@ -171,6 +171,19 @@
171
171
  .arch summary .emptybtn { margin-left:auto; border:1px solid var(--rule2); background:#fff; color:var(--muted); border-radius:8px; font-size:11px; padding:2px 8px; cursor:pointer; }
172
172
  .arch summary .emptybtn:hover { background:var(--bad); border-color:var(--bad); color:#fff; }
173
173
  .arch summary { display:flex; align-items:center; gap:6px; }
174
+ .live .prog { margin-top:12px; }
175
+ .live .bar { height:4px; border-radius:99px; background:var(--rule); overflow:hidden; }
176
+ .live .bar i { display:block; height:100%; background:var(--amber); border-radius:99px;
177
+ transition:width .45s cubic-bezier(.2,.7,.3,1); }
178
+ .live .under { display:flex; align-items:baseline; gap:8px; margin-top:7px; font-size:12.5px; color:var(--muted); }
179
+ .live .under .sp { flex:1; }
180
+ .live .count { font-variant-numeric:tabular-nums; }
181
+ .live .quip { color:var(--ink); font-weight:500; }
182
+ .live .elapsed { font-variant-numeric:tabular-nums; opacity:.7; }
183
+ .spin { width:9px; height:9px; border-radius:99px; background:var(--amber); flex:none;
184
+ animation:pulse 1.4s ease-in-out infinite; }
185
+ @keyframes pulse { 0%,100% { opacity:.35; transform:scale(.82) } 50% { opacity:1; transform:scale(1) } }
186
+ @media (prefers-reduced-motion:reduce) { .spin { animation:none; opacity:.9 } .live .bar i { transition:none } }
174
187
  </style>
175
188
  </head>
176
189
  <body>
@@ -186,7 +199,11 @@
186
199
  <div class="wrap two">
187
200
  <div class="col-main">
188
201
  <div class="live" id="live">
189
- <div class="head"><span class="dot live" id="liveDot"></span><b id="liveWho"></b><span id="liveWhat"></span></div>
202
+ <div class="head"><span class="spin" id="liveDot"></span><b id="liveWho"></b><span id="liveWhat"></span></div>
203
+ <div class="prog" id="liveProg" style="display:none">
204
+ <div class="bar"><i id="liveBar" style="width:0%"></i></div>
205
+ <div class="under"><span class="quip" id="liveQuip"></span><span class="sp"></span><span class="count" id="liveCount"></span><span class="elapsed" id="liveElapsed"></span></div>
206
+ </div>
190
207
  <div class="lines" id="liveLines"></div>
191
208
  </div>
192
209
  <div class="pin" id="pin">
@@ -613,14 +630,107 @@ function askSecrets(r) {
613
630
 
614
631
  // ---- the live feed: an agent working shows up here as it works -------------
615
632
  function watchActivity() {
633
+
634
+ // While a take runs there is nothing to look at and a lot of time to look at
635
+ // it. A bar for how far in, a clock for how long, and something to read that
636
+ // is not a spinner. Recording-flavoured, because that is what is happening,
637
+ // and none of them claim progress they do not have.
638
+ const QUIPS = [
639
+ 'Rolling.', 'Lining up the shot.', 'Waiting on your app.',
640
+ 'Not fumbling the take.', 'Holding the frame.', 'Letting the app think.',
641
+ 'Clicking things, patiently.', 'Doing the boring part.',
642
+ 'Take one. Also the only take.', 'Typing at a speed people can read.',
643
+ 'Watching the spinner so you do not have to.', 'No sighing this time.',
644
+ 'Getting it right the first time, on purpose.',
645
+ ];
646
+ let quipAt = 0, quipTimer = null, elapsedTimer = null, runStart = 0, lastActivity = null;
647
+
648
+ function fmtElapsed(ms) {
649
+ const t = Math.max(0, Math.round(ms / 1000));
650
+ return t < 60 ? t + 's' : Math.floor(t / 60) + 'm ' + String(t % 60).padStart(2, '0') + 's';
651
+ }
652
+
653
+ function startLiveTickers(startedAt) {
654
+ const want = startedAt || Date.now();
655
+ if (runStart !== want) runStart = want; // a new run restarts the clock
656
+ if (!quipTimer) {
657
+ // Slow enough to read, and it only moves while something is happening.
658
+ quipTimer = setInterval(() => {
659
+ quipAt = (quipAt + 1) % QUIPS.length;
660
+ const q = $('liveQuip'); if (q) q.textContent = QUIPS[quipAt];
661
+ }, 4200);
662
+ }
663
+ if (!elapsedTimer) {
664
+ elapsedTimer = setInterval(() => {
665
+ const e = $('liveElapsed'); if (e) e.textContent = fmtElapsed(Date.now() - runStart);
666
+ // No events arrive while ffmpeg works, so the render bar advances on
667
+ // the clock or it would look frozen for the whole render.
668
+ if (lastActivity && lastActivity.progress && lastActivity.progress.phase === 'rendering') drawProgress(lastActivity);
669
+ }, 1000);
670
+ }
671
+ }
672
+ function stopLiveTickers() {
673
+ clearInterval(quipTimer); clearInterval(elapsedTimer);
674
+ quipTimer = elapsedTimer = null;
675
+ }
676
+
677
+ function drawProgress(a) {
678
+ const box = $('liveProg'); if (!box) return;
679
+ if (a) lastActivity = a;
680
+ const p = a && a.progress;
681
+ if (!a || !a.active) { box.style.display = 'none'; stopLiveTickers(); return; }
682
+ // Nothing has reported in five minutes: the run is over, or whatever was
683
+ // running is gone. Either way the bar is lying, so stop showing it.
684
+ if (p && p.since && Date.now() - p.since > 5 * 60_000) { box.style.display = 'none'; stopLiveTickers(); return; }
685
+ if (!p) { box.style.display = 'none'; stopLiveTickers(); return; }
686
+ // Measure THIS run, not the activity feed. The feed goes active on the
687
+ // first line and stays that way, so a window left open all day reported a
688
+ // four-minute take as 529 minutes.
689
+ startLiveTickers(p.runStart || a.startedAt);
690
+ if (!$('liveQuip').textContent) $('liveQuip').textContent = QUIPS[0];
691
+ $('liveElapsed').textContent = fmtElapsed(Date.now() - runStart);
692
+ // Rendering: there are no steps to count, but the last render of this demo
693
+ // measured itself, so the bar can run on that. Capped at 95% — it must not
694
+ // claim finished before it is, and an estimate is never a promise.
695
+ if (p && p.phase === 'rendering') {
696
+ box.style.display = '';
697
+ const eta = (p.etaSec || 0) * 1000;
698
+ const gone = Date.now() - (p.since || Date.now());
699
+ $('liveBar').style.width = (eta ? Math.min(95, Math.round((gone / eta) * 100)) : 0) + '%';
700
+ const left = Math.max(0, Math.round((eta - gone) / 1000));
701
+ $('liveCount').textContent = eta
702
+ ? (left > 0 ? `rendering · about ${left}s left` : 'rendering · nearly there')
703
+ : 'rendering';
704
+ return;
705
+ }
706
+ if (!p || !p.total) {
707
+ // Working, but nothing countable yet — show the clock, not a fake bar.
708
+ box.style.display = '';
709
+ $('liveBar').style.width = '0%';
710
+ $('liveCount').textContent = '';
711
+ return;
712
+ }
713
+ box.style.display = '';
714
+ // The last STEP is not the end of the take: the final hold still has to
715
+ // play, the video still has to be finalised and its dead tail trimmed. A
716
+ // bar sitting at 100% while the thing is visibly still going is the kind
717
+ // of small lie that makes someone stop trusting the rest of the window.
718
+ const pct = Math.min(96, Math.round((p.step / p.total) * 96));
719
+ $('liveBar').style.width = pct + '%';
720
+ $('liveCount').textContent = `step ${p.step} of ${p.total}`;
721
+ }
722
+
616
723
  const es = new EventSource('/api/activity/stream');
617
724
  const apply = (a) => {
618
725
  if (!a || (!a.active && !a.lines?.length)) return;
619
726
  $('live').classList.add('show');
620
- $('liveDot').className = 'dot ' + (a.active ? 'live' : 'ok');
727
+ // 'spin' has to survive: this line used to replace it wholesale, so the
728
+ // spinner existed in the markup and never once appeared.
729
+ $('liveDot').className = a.active ? 'spin' : 'dot ok';
621
730
  $('liveWho').textContent = a.active ? (a.who || 'your agent') : 'Done';
622
731
  $('liveWhat').textContent = a.demo ? `· ${a.demo}` : '';
623
732
  $('liveLines').innerHTML = (a.lines || []).slice(-4).map((l) => `<div>${esc(l)}</div>`).join('');
733
+ drawProgress(a);
624
734
  };
625
735
  es.addEventListener('state', (ev) => {
626
736
  const d = JSON.parse(ev.data);
@@ -631,6 +741,7 @@ function watchActivity() {
631
741
  for (const r of d.secrets || []) askSecrets(r);
632
742
  });
633
743
  es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
744
+ es.addEventListener('progress', (ev) => apply(JSON.parse(ev.data)));
634
745
  es.addEventListener('secrets', (ev) => askSecrets(JSON.parse(ev.data)));
635
746
  es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
636
747
  es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };
package/dist/ui/server.js CHANGED
@@ -441,12 +441,18 @@ function noteActivity(b) {
441
441
  if (activity.lines.length > 200)
442
442
  activity.lines.shift();
443
443
  }
444
+ if (b.progress) {
445
+ activity.progress = b.progress.clear ? undefined : { ...b.progress, since: Date.now() };
446
+ if (b.progress.demo)
447
+ activity.demo = b.progress.demo;
448
+ }
444
449
  if (b.done) {
445
450
  activity.active = false;
446
451
  activity.finishedAt = Date.now();
452
+ activity.progress = undefined;
447
453
  }
448
454
  for (const w of activityWatchers)
449
- w({ type: b.done ? "finished" : "line", data: { ...activity, latest: b.line } });
455
+ w({ type: b.done ? "finished" : b.progress ? "progress" : "line", data: { ...activity, latest: b.line } });
450
456
  }
451
457
  export function serve(port) {
452
458
  loadDotenv(ROOT);
@@ -471,13 +477,19 @@ export function serve(port) {
471
477
  // root when deployed, so its asset paths are relative either way.
472
478
  // releases.json sits beside index.html when deployed, so the preview
473
479
  // has to serve it from the same two prefixes the page might be under.
474
- const mrel = /^(?:\/landing)?\/releases\.json$/.exec(p);
475
- if (mrel && (req.method === "GET" || req.method === "HEAD")) {
476
- const f = path.join(PKG_ROOT, "site", "releases.json");
480
+ // Files that sit at the root of site/ when deployed — releases.json, the
481
+ // photo in the maker note. The preview has to serve them from the same
482
+ // two prefixes the page might be under, or it shows a broken page for a
483
+ // site that is actually fine.
484
+ const mroot = /^(?:\/landing)?\/([A-Za-z0-9._-]+\.(?:json|jpg|jpeg|png|svg|webp|ico|txt))$/.exec(p);
485
+ if (mroot && (req.method === "GET" || req.method === "HEAD")) {
486
+ const f = path.join(PKG_ROOT, "site", mroot[1]);
477
487
  if (!fs.existsSync(f))
478
488
  return json(res, 404, { error: "not found" });
479
- res.writeHead(200, { "content-type": "application/json" });
480
- return res.end(req.method === "HEAD" ? undefined : fs.readFileSync(f));
489
+ res.writeHead(200, { "content-type": MIME[path.extname(f)] ?? "application/octet-stream", "content-length": fs.statSync(f).size });
490
+ if (req.method === "HEAD")
491
+ return res.end();
492
+ return fs.createReadStream(f).pipe(res);
481
493
  }
482
494
  const msite = /^(?:\/landing)?\/(media|logos)\/([A-Za-z0-9._-]+)$/.exec(p);
483
495
  if (msite && (req.method === "GET" || req.method === "HEAD")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "_readme": "What's new, written for the person using Retake — not for whoever wrote it. Both the landing page and the Retake window read THIS file; there is no second copy. Rules for adding one: say what changed for them, name no files, no flags they will not type, no words like refactor or regression. If a line only makes sense to someone who has read the source, it does not belong here. Newest first. Add an entry on every release that changes what people see.",
3
3
  "releases": [
4
+ {
5
+ "version": "0.4.1",
6
+ "date": "2026-08-26",
7
+ "headline": "You can see what your agent is doing, and it finishes what it started",
8
+ "notes": [
9
+ "A progress bar while a demo records — how many steps in, how long it has been going, and roughly how long the render has left. The window used to just say \"Recording…\" and then nothing at all, sometimes for a quarter of an hour.",
10
+ "If your agent disconnects mid-recording, the recording now finishes and saves instead of dying halfway.",
11
+ "And if a recording is killed outright, it leaves a note saying which part it reached — so you can pick up from there instead of starting the whole thing again."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.4.0",
6
16
  "date": "2026-08-25",