replicas-cli 0.2.328 → 0.2.330

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/dist/index.mjs +733 -140
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -8086,7 +8086,7 @@ Anything else gets \`exec\`'d verbatim, so \`replicas computer launch xeyes\` wo
8086
8086
  When opening a known page, prefer passing the URL directly: \`replicas computer launch chrome http://localhost:3000/\`.
8087
8087
 
8088
8088
  ### \`replicas computer record start <path> [--fps N]\`
8089
- Starts an ffmpeg screen recorder. Output is a fragmented MP4 (still playable if the workspace dies mid-record). Default 60fps; drop to 30 if the workspace is CPU-constrained.
8089
+ Starts an ffmpeg screen recorder. Output is post-processed when stopped: action windows stay at normal speed, idle gaps accelerate, click moments get eased camera zoom, and a synthetic cursor animates between logged mouse positions. The raw capture is fragmented MP4 while active (still playable if the workspace dies mid-record). Default 60fps; drop to 30 if the workspace is CPU-constrained.
8090
8090
 
8091
8091
  Only one recording at a time. Re-running \`start\` while one is active fails - call \`stop\` first.
8092
8092
 
@@ -9537,7 +9537,7 @@ var HOOK_EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
9537
9537
  var REPLICAS_CONFIG_FILENAMES = ["replicas.json", "replicas.yaml", "replicas.yml"];
9538
9538
 
9539
9539
  // ../shared/src/cli-version.ts
9540
- var CLI_VERSION = "0.2.328";
9540
+ var CLI_VERSION = "0.2.330";
9541
9541
 
9542
9542
  // ../shared/src/engine/environment.ts
9543
9543
  var DESKTOP_NOVNC_PORT = 6080;
@@ -14481,12 +14481,17 @@ async function mediaListCommand(options) {
14481
14481
  }
14482
14482
  }
14483
14483
 
14484
- // src/commands/computer.ts
14485
- import { spawn as spawn3, spawnSync } from "child_process";
14484
+ // src/commands/computer/index.ts
14485
+ import { spawn as spawn4, spawnSync as spawnSync3 } from "child_process";
14486
14486
  import { createHash as createHash2 } from "crypto";
14487
- import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "fs";
14488
- import { dirname, isAbsolute, resolve } from "path";
14487
+ import { closeSync, copyFileSync as copyFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, readSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
14488
+ import { dirname as dirname2 } from "path";
14489
14489
  import chalk20 from "chalk";
14490
+
14491
+ // src/commands/computer/desktop.ts
14492
+ import { spawnSync } from "child_process";
14493
+ import { existsSync } from "fs";
14494
+ import { isAbsolute, resolve } from "path";
14490
14495
  var STATE_DIR = process.env.REPLICAS_DESKTOP_STATE_DIR || "/tmp/replicas-computer";
14491
14496
  var DEFAULT_DISPLAY = process.env.REPLICAS_DESKTOP_DISPLAY || ":99";
14492
14497
  var NOVNC_PORT = process.env.REPLICAS_DESKTOP_NOVNC_PORT ? parseInt(process.env.REPLICAS_DESKTOP_NOVNC_PORT, 10) : DESKTOP_NOVNC_PORT;
@@ -14547,6 +14552,23 @@ function getDisplayDimensions() {
14547
14552
  if (!match) fail("could not read display dimensions from xdpyinfo");
14548
14553
  return { width: Number.parseInt(match[1], 10), height: Number.parseInt(match[2], 10) };
14549
14554
  }
14555
+ function getMouseLocation() {
14556
+ const out = tryDisplayCmd("xdotool", ["getmouselocation", "--shell"]);
14557
+ if (!out) return null;
14558
+ const values = Object.fromEntries(
14559
+ out.split("\n").map((line) => {
14560
+ const [key, value] = line.split("=");
14561
+ return [key.toLowerCase(), Number.parseInt(value, 10)];
14562
+ })
14563
+ );
14564
+ if (!Number.isFinite(values.x) || !Number.isFinite(values.y)) return null;
14565
+ return {
14566
+ x: values.x,
14567
+ y: values.y,
14568
+ screen: Number.isFinite(values.screen) ? values.screen : 0,
14569
+ window: Number.isFinite(values.window) ? values.window : 0
14570
+ };
14571
+ }
14550
14572
  function parseScreenCoord(value, label, size) {
14551
14573
  if (value.endsWith("%")) {
14552
14574
  const rawPct = value.slice(0, -1);
@@ -14562,8 +14584,655 @@ function parseScreenCoord(value, label, size) {
14562
14584
  function resolvePath(p) {
14563
14585
  return isAbsolute(p) ? p : resolve(process.cwd(), p);
14564
14586
  }
14587
+ function configuredDesktopDimensions() {
14588
+ const width = parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
14589
+ const height = parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
14590
+ if (!Number.isFinite(width) || width <= 0) fail(`REPLICAS_DESKTOP_WIDTH must be a positive integer`);
14591
+ if (!Number.isFinite(height) || height <= 0) fail(`REPLICAS_DESKTOP_HEIGHT must be a positive integer`);
14592
+ return { width, height };
14593
+ }
14594
+ function clamp(n, min, max) {
14595
+ return Math.min(max, Math.max(min, n));
14596
+ }
14597
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
14598
+
14599
+ // src/commands/computer/recording.ts
14600
+ import { spawn as spawn3 } from "child_process";
14601
+ import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
14602
+ import { dirname } from "path";
14603
+
14604
+ // src/commands/computer/recording/render.ts
14605
+ import { spawnSync as spawnSync2 } from "child_process";
14606
+ import { copyFileSync, rmSync, writeFileSync } from "fs";
14607
+
14608
+ // src/commands/computer/recording/config.ts
14609
+ var cameraMotion = {
14610
+ clickLeadSeconds: 1.2,
14611
+ clickTailSeconds: 0.5,
14612
+ actionLeadSeconds: 0.9,
14613
+ clickZoom: 1.24,
14614
+ closeClickOutputGapSeconds: 6,
14615
+ clickMoveSeconds: 1.2,
14616
+ returnSeconds: 1,
14617
+ minSegmentSeconds: 0.05
14618
+ };
14619
+ var cursorMotion = {
14620
+ minMoveSeconds: 0.38,
14621
+ maxMoveSeconds: 1.7,
14622
+ speedPxPerSecond: 700,
14623
+ defaultLeadSeconds: 0.16,
14624
+ clickShrinkSeconds: 0.08,
14625
+ clickGrowSeconds: 0.14,
14626
+ clickDwellGapStartSeconds: 0.75,
14627
+ clickDwellFullGapSeconds: 3.2,
14628
+ clickMinDwellSeconds: 0.12,
14629
+ clickMaxDwellSeconds: 1.15,
14630
+ maxDwellGapShare: 0.45,
14631
+ dragLeadSeconds: 0.55,
14632
+ maxKeyframes: 120
14633
+ };
14634
+ var cursorStyle = {
14635
+ size: 30,
14636
+ clickSize: 24
14637
+ };
14638
+
14639
+ // src/commands/computer/recording/ffmpeg-expressions.ts
14640
+ function expressionNumber(n) {
14641
+ return Number.isInteger(n) ? String(n) : n.toFixed(3);
14642
+ }
14643
+ function mixNumber(from, to, progress) {
14644
+ return from + (to - from) * progress;
14645
+ }
14646
+ function easeInOutSmootherNumber(progress) {
14647
+ const p = clamp(progress, 0, 1);
14648
+ return p * p * p * (p * (p * 6 - 15) + 10);
14649
+ }
14650
+ function easeInOutSmootherExpression(progress) {
14651
+ const p = `(${progress})`;
14652
+ return `${p}*${p}*${p}*(${p}*(${p}*6-15)+10)`;
14653
+ }
14654
+ function progressOverTime(timeExpression, seconds) {
14655
+ return easeInOutSmootherExpression(`min(max((${timeExpression})/${seconds.toFixed(3)}\\,0)\\,1)`);
14656
+ }
14657
+ function progressBetween(start, duration) {
14658
+ return easeInOutSmootherExpression(`min(max((t-${start.toFixed(3)})/${duration.toFixed(3)}\\,0)\\,1)`);
14659
+ }
14660
+ function mixExpression(from, to, progress) {
14661
+ return `${expressionNumber(from)}+(${expressionNumber(to - from)})*${progress}`;
14662
+ }
14663
+ function cropOrigin(point, zoom, size) {
14664
+ return clamp(point * zoom - size / 2, 0, size * zoom - size);
14665
+ }
14666
+ function easedCropOrigin(fromPoint, toPoint, fromZoom, toZoom, size, progress) {
14667
+ return mixExpression(
14668
+ cropOrigin(fromPoint, fromZoom, size),
14669
+ cropOrigin(toPoint, toZoom, size),
14670
+ progress
14671
+ );
14672
+ }
14673
+ function betweenExpression(start, end) {
14674
+ return `between(t\\,${start.toFixed(3)}\\,${end.toFixed(3)})`;
14675
+ }
14676
+
14677
+ // src/commands/computer/recording/timeline.ts
14678
+ function centerOf(size) {
14679
+ return { x: size.width / 2, y: size.height / 2 };
14680
+ }
14681
+ function actionPoint(action, size) {
14682
+ const x = action.toX ?? action.x ?? size.width / 2;
14683
+ const y = action.toY ?? action.y ?? size.height / 2;
14684
+ return { x: clamp(x, 0, size.width), y: clamp(y, 0, size.height) };
14685
+ }
14686
+ function idleSpeed(duration) {
14687
+ if (duration < 1.2) return 1;
14688
+ return clamp(1 + Math.log1p(duration - 0.8) * 1.8, 1.5, 5);
14689
+ }
14690
+ function renderedGapSeconds(duration) {
14691
+ return duration / idleSpeed(duration);
14692
+ }
14693
+ function timelineActions(actions) {
14694
+ return actions.filter((action) => action.type !== "key" && action.type !== "move");
14695
+ }
14696
+ function actionWindow(action, duration) {
14697
+ const shouldZoom = action.type === "click";
14698
+ const at = clamp(action.atMs / 1e3, 0, duration);
14699
+ const start = clamp(at - (shouldZoom ? cameraMotion.clickLeadSeconds : cameraMotion.actionLeadSeconds), 0, duration);
14700
+ const tail = shouldZoom ? cameraMotion.clickTailSeconds : action.type === "type" ? 1.5 : 0.9;
14701
+ return { start, end: clamp(at + tail, start, duration), shouldZoom };
14702
+ }
14703
+ function nextClickStart(actions, index, duration) {
14704
+ const nextClick = actions.slice(index + 1).find((candidate) => candidate.type === "click");
14705
+ return nextClick ? clamp(nextClick.atMs / 1e3 - cameraMotion.clickLeadSeconds, 0, duration) : null;
14706
+ }
14707
+ function shouldPreserveZoom(nextStart, cursor, lastZoom) {
14708
+ return lastZoom > 1 && nextStart !== null && nextStart > cursor && renderedGapSeconds(nextStart - cursor) <= cameraMotion.closeClickOutputGapSeconds;
14709
+ }
14710
+ function fullFrameSegment(duration, size) {
14711
+ const center = centerOf(size);
14712
+ return {
14713
+ start: 0,
14714
+ end: duration,
14715
+ speed: 1,
14716
+ motionSeconds: cameraMotion.returnSeconds,
14717
+ fromZoom: 1,
14718
+ zoom: 1,
14719
+ fromX: center.x,
14720
+ fromY: center.y,
14721
+ ...center
14722
+ };
14723
+ }
14724
+ function buildRecordingSegments(actions, duration, size) {
14725
+ const actionsForTimeline = timelineActions(actions);
14726
+ if (actionsForTimeline.length === 0) return [fullFrameSegment(duration, size)];
14727
+ const center = centerOf(size);
14728
+ const segments = [];
14729
+ let cursor = 0;
14730
+ let lastPoint = center;
14731
+ let lastZoom = 1;
14732
+ for (let i = 0; i < actionsForTimeline.length; i++) {
14733
+ const action = actionsForTimeline[i];
14734
+ const { start, end, shouldZoom } = actionWindow(action, duration);
14735
+ const preserveZoom = !shouldZoom && shouldPreserveZoom(nextClickStart(actionsForTimeline, i, duration), cursor, lastZoom);
14736
+ const point = shouldZoom ? actionPoint(action, size) : preserveZoom ? lastPoint : center;
14737
+ const zoom = shouldZoom ? cameraMotion.clickZoom : preserveZoom ? lastZoom : 1;
14738
+ if (start > cursor + cameraMotion.minSegmentSeconds) {
14739
+ const gap = start - cursor;
14740
+ const speed = idleSpeed(gap);
14741
+ const bridgeZoom = shouldZoom && lastZoom > 1 && renderedGapSeconds(gap) <= cameraMotion.closeClickOutputGapSeconds;
14742
+ segments.push({
14743
+ start: cursor,
14744
+ end: start,
14745
+ speed,
14746
+ motionSeconds: bridgeZoom ? Math.min(cameraMotion.clickMoveSeconds, Math.max(0.18, gap / speed)) : cameraMotion.returnSeconds,
14747
+ fromZoom: lastZoom,
14748
+ zoom: bridgeZoom ? cameraMotion.clickZoom : zoom,
14749
+ fromX: lastPoint.x,
14750
+ fromY: lastPoint.y,
14751
+ ...point
14752
+ });
14753
+ lastZoom = bridgeZoom ? cameraMotion.clickZoom : zoom;
14754
+ lastPoint = point;
14755
+ }
14756
+ const actionStart = Math.max(cursor, start);
14757
+ if (end > actionStart + cameraMotion.minSegmentSeconds) {
14758
+ segments.push({
14759
+ start: actionStart,
14760
+ end,
14761
+ speed: 1,
14762
+ motionSeconds: shouldZoom ? cameraMotion.clickMoveSeconds : cameraMotion.returnSeconds,
14763
+ fromZoom: lastZoom,
14764
+ zoom,
14765
+ fromX: lastPoint.x,
14766
+ fromY: lastPoint.y,
14767
+ ...point
14768
+ });
14769
+ cursor = end;
14770
+ lastZoom = zoom;
14771
+ }
14772
+ lastPoint = point;
14773
+ }
14774
+ if (duration > cursor + cameraMotion.minSegmentSeconds) {
14775
+ segments.push({
14776
+ start: cursor,
14777
+ end: duration,
14778
+ speed: idleSpeed(duration - cursor),
14779
+ motionSeconds: cameraMotion.returnSeconds,
14780
+ fromZoom: lastZoom,
14781
+ zoom: 1,
14782
+ fromX: lastPoint.x,
14783
+ fromY: lastPoint.y,
14784
+ ...center
14785
+ });
14786
+ }
14787
+ return segments.filter((segment) => segment.end > segment.start + cameraMotion.minSegmentSeconds);
14788
+ }
14789
+
14790
+ // src/commands/computer/recording/cursor.ts
14791
+ var cursorHotspotRatio = { x: 8 / 32, y: 4 / 32 };
14792
+ function cursorAssets(stamp) {
14793
+ return {
14794
+ path: `/tmp/replicas-cursor-${stamp}.svg`,
14795
+ size: cursorStyle.size
14796
+ };
14797
+ }
14798
+ function clickDwellSeconds(gapSeconds) {
14799
+ if (gapSeconds <= cursorMotion.clickDwellGapStartSeconds) return cursorMotion.defaultLeadSeconds;
14800
+ const progress = (gapSeconds - cursorMotion.clickDwellGapStartSeconds) / (cursorMotion.clickDwellFullGapSeconds - cursorMotion.clickDwellGapStartSeconds);
14801
+ const dwell = mixNumber(
14802
+ cursorMotion.clickMinDwellSeconds,
14803
+ cursorMotion.clickMaxDwellSeconds,
14804
+ easeInOutSmootherNumber(progress)
14805
+ );
14806
+ return Math.min(dwell, gapSeconds * cursorMotion.maxDwellGapShare);
14807
+ }
14808
+ function arrivalLeadSeconds(action, gapSeconds) {
14809
+ return action.type === "click" ? clickDwellSeconds(gapSeconds) : cursorMotion.defaultLeadSeconds;
14810
+ }
14811
+ function cursorKeyframe(action, at, previousAt, size) {
14812
+ if (typeof action.x !== "number" || typeof action.y !== "number") return null;
14813
+ const point = actionPoint(action, size);
14814
+ const lead = arrivalLeadSeconds(action, at - previousAt);
14815
+ return { ...point, at, arriveAt: clamp(at - lead, previousAt, at) };
14816
+ }
14817
+ function dedupeKeyframes(points) {
14818
+ return points.sort((a, b) => a.at - b.at).filter(
14819
+ (point, index, all) => index === 0 || point.at - all[index - 1].at > 0.08 || Math.hypot(point.x - all[index - 1].x, point.y - all[index - 1].y) > 24
14820
+ );
14821
+ }
14822
+ function downsampleKeyframes(points, actions, size) {
14823
+ if (points.length <= cursorMotion.maxKeyframes) return points;
14824
+ const anchors = actions.filter((action) => action.type !== "move").map((action) => actionPoint(action, size));
14825
+ const result = points.filter(
14826
+ (point, index) => index === 0 || index === points.length - 1 || anchors.some((anchor) => Math.hypot(anchor.x - point.x, anchor.y - point.y) < 1)
14827
+ );
14828
+ const step = points.length / Math.max(1, cursorMotion.maxKeyframes - result.length);
14829
+ for (let i = 0; result.length < cursorMotion.maxKeyframes && i < points.length; i += step) {
14830
+ const point = points[Math.floor(i)];
14831
+ if (!result.includes(point)) result.push(point);
14832
+ }
14833
+ return result.sort((a, b) => a.at - b.at);
14834
+ }
14835
+ function cursorKeyframes(actions, duration, size) {
14836
+ const center = { x: size.width / 2, y: size.height / 2 };
14837
+ const points = [{ at: 0, arriveAt: 0, ...center }];
14838
+ for (const action of actions) {
14839
+ const at = clamp(action.atMs / 1e3, 0, duration);
14840
+ const previousAt = points[points.length - 1].at;
14841
+ if (action.type === "drag" && typeof action.x === "number" && typeof action.y === "number") {
14842
+ const startAt = clamp(at - cursorMotion.dragLeadSeconds, 0, duration);
14843
+ points.push({
14844
+ at: startAt,
14845
+ arriveAt: startAt,
14846
+ x: clamp(action.x, 0, size.width),
14847
+ y: clamp(action.y, 0, size.height)
14848
+ });
14849
+ if (typeof action.toX === "number" && typeof action.toY === "number") {
14850
+ points.push({
14851
+ at,
14852
+ arriveAt: at,
14853
+ x: clamp(action.toX, 0, size.width),
14854
+ y: clamp(action.toY, 0, size.height)
14855
+ });
14856
+ }
14857
+ continue;
14858
+ }
14859
+ const point = cursorKeyframe(action, at, previousAt, size);
14860
+ if (point) points.push(point);
14861
+ }
14862
+ return downsampleKeyframes(dedupeKeyframes(points), actions, size);
14863
+ }
14864
+ function cursorAxisExpression(points, axis) {
14865
+ if (points.length === 0) return "0";
14866
+ let expression = expressionNumber(points[points.length - 1][axis]);
14867
+ for (let i = points.length - 2; i >= 0; i--) {
14868
+ const from = points[i];
14869
+ const to = points[i + 1];
14870
+ const arriveAt = clamp(to.arriveAt, from.at, to.at);
14871
+ const moveWindow = Math.max(1e-3, arriveAt - from.at);
14872
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
14873
+ const moveSeconds = Math.min(
14874
+ moveWindow,
14875
+ Math.max(cursorMotion.minMoveSeconds, Math.min(cursorMotion.maxMoveSeconds, distance / cursorMotion.speedPxPerSecond))
14876
+ );
14877
+ const moveStart = Math.max(from.at, arriveAt - moveSeconds);
14878
+ const duration = Math.max(1e-3, arriveAt - moveStart);
14879
+ const interpolated = mixExpression(from[axis], to[axis], progressBetween(moveStart, duration));
14880
+ expression = `if(lte(t\\,${moveStart.toFixed(3)})\\,${expressionNumber(from[axis])}\\,if(lte(t\\,${to.at.toFixed(3)})\\,${interpolated}\\,${expression}))`;
14881
+ }
14882
+ return expression;
14883
+ }
14884
+ function cursorSizeExpression(actions, normalSize) {
14885
+ let expression = expressionNumber(normalSize);
14886
+ const clicks = actions.filter((action) => action.type === "click").sort((a, b) => b.atMs - a.atMs);
14887
+ for (const action of clicks) {
14888
+ const at = action.atMs / 1e3;
14889
+ const shrinkStart = Math.max(0, at - cursorMotion.clickShrinkSeconds);
14890
+ const shrink = mixExpression(
14891
+ normalSize,
14892
+ cursorStyle.clickSize,
14893
+ progressBetween(shrinkStart, Math.max(1e-3, at - shrinkStart))
14894
+ );
14895
+ const grow = mixExpression(cursorStyle.clickSize, normalSize, progressBetween(at, cursorMotion.clickGrowSeconds));
14896
+ expression = `if(${betweenExpression(shrinkStart, at)}\\,${shrink}\\,if(${betweenExpression(at, at + cursorMotion.clickGrowSeconds)}\\,${grow}\\,${expression}))`;
14897
+ }
14898
+ return expression;
14899
+ }
14900
+ function cursorOverlayPositionExpression(cursor, size, hotspotRatio, overlaySize) {
14901
+ return `max(0\\,min(${expressionNumber(size)}-${overlaySize}\\,(${cursor})-${hotspotRatio.toFixed(6)}*${overlaySize}))`;
14902
+ }
14903
+ function cursorSvg(size) {
14904
+ return `<svg width="${size}" height="${size}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M7.7 3.4c-1.45 0-2.62 1.17-2.62 2.62v19.7c0 2.46 3.02 3.65 4.7 1.85l5.06-5.44a5.42 5.42 0 0 1 3.96-1.73h5.82c2.48 0 3.63-3.08 1.76-4.7L9.42 4.04A2.62 2.62 0 0 0 7.7 3.4Z" fill="white" fill-opacity="0.98" stroke="black" stroke-opacity="0.88" stroke-width="2.9" stroke-linejoin="round"/></svg>`;
14905
+ }
14906
+ function buildCursorOverlayFilter(actions, duration, size, assets, projectCursor = null, inputLabel = "[screen]", outputLabel = "[v]") {
14907
+ const points = cursorKeyframes(actions, duration, size);
14908
+ const rawCursorX = cursorAxisExpression(points, "x");
14909
+ const rawCursorY = cursorAxisExpression(points, "y");
14910
+ const cursorX = projectCursor ? projectCursor.x(rawCursorX) : rawCursorX;
14911
+ const cursorY = projectCursor ? projectCursor.y(rawCursorY) : rawCursorY;
14912
+ const cursorSize = cursorSizeExpression(actions, assets.size);
14913
+ const cursorOverlayX = cursorOverlayPositionExpression(cursorX, size.width, cursorHotspotRatio.x, "overlay_w");
14914
+ const cursorOverlayY = cursorOverlayPositionExpression(cursorY, size.height, cursorHotspotRatio.y, "overlay_h");
14915
+ return `[1:v]scale=w='${cursorSize}':h='${cursorSize}':eval=frame[cursor];${inputLabel}[cursor]overlay=x='${cursorOverlayX}':y='${cursorOverlayY}':eof_action=repeat:shortest=1:eval=frame${outputLabel}`;
14916
+ }
14917
+
14918
+ // src/commands/computer/recording/render.ts
14919
+ function videoDuration(path6) {
14920
+ const r = spawnSync2("ffprobe", [
14921
+ "-v",
14922
+ "error",
14923
+ "-show_entries",
14924
+ "format=duration",
14925
+ "-of",
14926
+ "default=noprint_wrappers=1:nokey=1",
14927
+ path6
14928
+ ], { stdio: "pipe" });
14929
+ if (r.status !== 0) fail(`ffprobe failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
14930
+ const duration = Number.parseFloat(r.stdout?.toString().trim() ?? "");
14931
+ if (!Number.isFinite(duration) || duration <= 0) fail(`could not read recording duration for ${path6}`);
14932
+ return duration;
14933
+ }
14934
+ function renderedSegmentSpans(segments) {
14935
+ let outputStart = 0;
14936
+ return segments.map((segment) => {
14937
+ const outputEnd = outputStart + (segment.end - segment.start) / segment.speed;
14938
+ const span = {
14939
+ rawStart: segment.start,
14940
+ rawEnd: segment.end,
14941
+ outputStart,
14942
+ outputEnd,
14943
+ speed: segment.speed
14944
+ };
14945
+ outputStart = outputEnd;
14946
+ return span;
14947
+ });
14948
+ }
14949
+ function outputTimeForRawTime(spans, rawTime) {
14950
+ const span = spans.find((candidate) => rawTime >= candidate.rawStart && rawTime <= candidate.rawEnd);
14951
+ if (span) return span.outputStart + (rawTime - span.rawStart) / span.speed;
14952
+ if (spans.length === 0) return rawTime;
14953
+ if (rawTime <= spans[0].rawStart) return spans[0].outputStart;
14954
+ const lastSpan = spans[spans.length - 1];
14955
+ if (rawTime >= lastSpan.rawEnd) return lastSpan.outputEnd;
14956
+ return spans.find((candidate) => rawTime < candidate.rawStart)?.outputStart ?? lastSpan.outputEnd;
14957
+ }
14958
+ function actionsOnRenderedTimeline(actions, spans) {
14959
+ return actions.map((action) => ({
14960
+ ...action,
14961
+ atMs: outputTimeForRawTime(spans, action.atMs / 1e3) * 1e3
14962
+ }));
14963
+ }
14964
+ function segmentMotionSeconds(segment) {
14965
+ return Math.min(segment.motionSeconds, Math.max(0.12, (segment.end - segment.start) / segment.speed));
14966
+ }
14967
+ function scaledFrameClampExpression(value, zoom, outputSize) {
14968
+ return `max(0\\,min(${outputSize}*(${zoom})-${outputSize}\\,${value}))`;
14969
+ }
14970
+ function cameraTransformExpressions(segment, size, timeExpression) {
14971
+ const progress = progressOverTime(timeExpression, segmentMotionSeconds(segment));
14972
+ const zoom = mixExpression(segment.fromZoom, segment.zoom, progress);
14973
+ return {
14974
+ zoom,
14975
+ cropX: scaledFrameClampExpression(
14976
+ easedCropOrigin(segment.fromX, segment.x, segment.fromZoom, segment.zoom, size.width, progress),
14977
+ zoom,
14978
+ size.width
14979
+ ),
14980
+ cropY: scaledFrameClampExpression(
14981
+ easedCropOrigin(segment.fromY, segment.y, segment.fromZoom, segment.zoom, size.height, progress),
14982
+ zoom,
14983
+ size.height
14984
+ )
14985
+ };
14986
+ }
14987
+ function projectedCursorExpression(rawCursor, axis, segments, spans, size) {
14988
+ let expression = rawCursor;
14989
+ for (let i = segments.length - 1; i >= 0; i--) {
14990
+ const span = spans[i];
14991
+ const camera = cameraTransformExpressions(segments[i], size, `t-${span.outputStart.toFixed(3)}`);
14992
+ const crop = axis === "x" ? camera.cropX : camera.cropY;
14993
+ const projected = `(${rawCursor})*(${camera.zoom})-(${crop})`;
14994
+ expression = `if(${betweenExpression(span.outputStart, span.outputEnd)}\\,${projected}\\,${expression})`;
14995
+ }
14996
+ return expression;
14997
+ }
14998
+ function renderRecording(rawPath, target, actions, fps, size) {
14999
+ if (actions.length === 0) {
15000
+ copyFileSync(rawPath, target);
15001
+ return;
15002
+ }
15003
+ const duration = videoDuration(rawPath);
15004
+ const segments = buildRecordingSegments(actions, duration, size);
15005
+ if (segments.length === 0) {
15006
+ copyFileSync(rawPath, target);
15007
+ return;
15008
+ }
15009
+ const stamp = `${process.pid}-${Date.now()}`;
15010
+ const cursor = cursorAssets(stamp);
15011
+ writeFileSync(cursor.path, cursorSvg(cursor.size));
15012
+ const spans = renderedSegmentSpans(segments);
15013
+ const renderedDuration = spans.length ? spans[spans.length - 1].outputEnd : duration;
15014
+ const renderedActions = actionsOnRenderedTimeline(actions, spans);
15015
+ const cursorFilter = buildCursorOverlayFilter(
15016
+ renderedActions,
15017
+ renderedDuration,
15018
+ size,
15019
+ cursor,
15020
+ {
15021
+ x: (cursorX) => projectedCursorExpression(cursorX, "x", segments, spans, size),
15022
+ y: (cursorY) => projectedCursorExpression(cursorY, "y", segments, spans, size)
15023
+ }
15024
+ );
15025
+ const screenLabels = segments.map((_, index) => `[screen${index}]`).join("");
15026
+ const screenSplitFilter = `[0:v]split=${segments.length}${screenLabels}`;
15027
+ const filters = segments.map((segment, index) => {
15028
+ const { zoom, cropX, cropY } = cameraTransformExpressions(segment, size, "t");
15029
+ return `[screen${index}]trim=start=${segment.start.toFixed(3)}:end=${segment.end.toFixed(3)},setpts=(PTS-STARTPTS)/${segment.speed.toFixed(3)},scale=w=${size.width}*(${zoom}):h=${size.height}*(${zoom}):eval=frame,crop=${size.width}:${size.height}:x=${cropX}:y=${cropY},setsar=1[v${index}]`;
15030
+ });
15031
+ const concatInputs = segments.map((_, index) => `[v${index}]`).join("");
15032
+ const filter = `${screenSplitFilter};${filters.join(";")};${concatInputs}concat=n=${segments.length}:v=1:a=0[screen];${cursorFilter}`;
15033
+ const filterPath = `/tmp/replicas-recording-filter-${stamp}.ffgraph`;
15034
+ writeFileSync(filterPath, filter);
15035
+ try {
15036
+ const r = spawnSync2("ffmpeg", [
15037
+ "-y",
15038
+ "-hide_banner",
15039
+ "-loglevel",
15040
+ "warning",
15041
+ "-i",
15042
+ rawPath,
15043
+ "-loop",
15044
+ "1",
15045
+ "-i",
15046
+ cursor.path,
15047
+ "-filter_complex_script",
15048
+ filterPath,
15049
+ "-map",
15050
+ "[v]",
15051
+ "-r",
15052
+ String(fps),
15053
+ "-c:v",
15054
+ "libx264",
15055
+ "-preset",
15056
+ "veryfast",
15057
+ "-crf",
15058
+ "23",
15059
+ "-pix_fmt",
15060
+ "yuv420p",
15061
+ "-movflags",
15062
+ "+faststart",
15063
+ target
15064
+ ], { stdio: "pipe", maxBuffer: 20 * 1024 * 1024 });
15065
+ if (r.status !== 0) {
15066
+ fail(`recording post-processing failed: ${r.error?.message || r.stderr?.toString().trim() || `exit ${r.status}`}`);
15067
+ }
15068
+ } finally {
15069
+ rmSync(filterPath, { force: true });
15070
+ rmSync(cursor.path, { force: true });
15071
+ }
15072
+ }
15073
+
15074
+ // src/commands/computer/recording.ts
15075
+ var RECORD_PID_FILE = `${STATE_DIR}/ffmpeg.pid`;
15076
+ var RECORD_PATH_FILE = `${STATE_DIR}/recording-path.txt`;
15077
+ var RECORD_RAW_PATH_FILE = `${STATE_DIR}/recording-raw-path.txt`;
15078
+ var RECORD_STARTED_AT_FILE = `${STATE_DIR}/recording-started-at.txt`;
15079
+ var RECORD_FPS_FILE = `${STATE_DIR}/recording-fps.txt`;
15080
+ var RECORD_DIMENSIONS_FILE = `${STATE_DIR}/recording-dimensions.json`;
15081
+ var RECORD_ACTIONS_FILE = `${STATE_DIR}/recording-actions.jsonl`;
15082
+ function recordingStartedAt() {
15083
+ if (!existsSync2(RECORD_STARTED_AT_FILE)) return null;
15084
+ const startedAt = Number.parseInt(readFileSync(RECORD_STARTED_AT_FILE, "utf8").trim(), 10);
15085
+ return Number.isFinite(startedAt) ? startedAt : null;
15086
+ }
15087
+ function logRecordingAction(action) {
15088
+ const startedAt = recordingStartedAt();
15089
+ if (!startedAt) return;
15090
+ const atMs = Date.now() - startedAt;
15091
+ appendFileSync(RECORD_ACTIONS_FILE, `${JSON.stringify({ ...action, atMs })}
15092
+ `);
15093
+ }
15094
+ function readRecordingDimensions() {
15095
+ if (!existsSync2(RECORD_DIMENSIONS_FILE)) return configuredDesktopDimensions();
15096
+ try {
15097
+ const dimensions = JSON.parse(readFileSync(RECORD_DIMENSIONS_FILE, "utf8"));
15098
+ const width = dimensions?.width;
15099
+ const height = dimensions?.height;
15100
+ if (typeof width === "number" && Number.isFinite(width) && width > 0 && typeof height === "number" && Number.isFinite(height) && height > 0) {
15101
+ return { width, height };
15102
+ }
15103
+ } catch {
15104
+ }
15105
+ fail("invalid recording dimensions state");
15106
+ }
15107
+ function isRecordingActionType(value) {
15108
+ return value === "click" || value === "drag" || value === "key" || value === "move" || value === "scroll" || value === "type";
15109
+ }
15110
+ function isOptionalNumber(value) {
15111
+ return value === void 0 || typeof value === "number";
15112
+ }
15113
+ function readRecordingActions() {
15114
+ if (!existsSync2(RECORD_ACTIONS_FILE)) return [];
15115
+ return readFileSync(RECORD_ACTIONS_FILE, "utf8").split("\n").filter(Boolean).flatMap((line) => {
15116
+ try {
15117
+ const value = JSON.parse(line);
15118
+ if (typeof value !== "object" || value === null) return [];
15119
+ const type = "type" in value ? value.type : void 0;
15120
+ const atMs = "atMs" in value ? value.atMs : void 0;
15121
+ const x = "x" in value ? value.x : void 0;
15122
+ const y = "y" in value ? value.y : void 0;
15123
+ const toX = "toX" in value ? value.toX : void 0;
15124
+ const toY = "toY" in value ? value.toY : void 0;
15125
+ if (!isRecordingActionType(type) || typeof atMs !== "number" || !Number.isFinite(atMs) || !isOptionalNumber(x) || !isOptionalNumber(y) || !isOptionalNumber(toX) || !isOptionalNumber(toY)) {
15126
+ return [];
15127
+ }
15128
+ return [{ type, atMs, x, y, toX, toY }];
15129
+ } catch {
15130
+ return [];
15131
+ }
15132
+ }).sort((a, b) => a.atMs - b.atMs);
15133
+ }
15134
+ async function computerRecordStartCommand(path6, options) {
15135
+ ensureServicesRunning();
15136
+ if (existsSync2(RECORD_PID_FILE)) {
15137
+ const pid = parseInt(readFileSync(RECORD_PID_FILE, "utf8").trim(), 10);
15138
+ if (Number.isFinite(pid)) {
15139
+ let alive = false;
15140
+ try {
15141
+ process.kill(pid, 0);
15142
+ alive = true;
15143
+ } catch {
15144
+ }
15145
+ if (alive) fail(`recording already in progress (pid ${pid}). run \`replicas computer record stop\` first.`);
15146
+ }
15147
+ }
15148
+ const target = resolvePath(path6);
15149
+ mkdirSync(dirname(target), { recursive: true });
15150
+ const fps = options.fps ? parseCoord(options.fps, "--fps") : 60;
15151
+ const { width, height } = configuredDesktopDimensions();
15152
+ mkdirSync(STATE_DIR, { recursive: true });
15153
+ const rawTarget = `${target}.raw-${Date.now()}.mp4`;
15154
+ rmSync2(RECORD_ACTIONS_FILE, { force: true });
15155
+ const child = spawn3("ffmpeg", [
15156
+ "-y",
15157
+ "-hide_banner",
15158
+ "-loglevel",
15159
+ "warning",
15160
+ "-f",
15161
+ "x11grab",
15162
+ "-framerate",
15163
+ String(fps),
15164
+ "-video_size",
15165
+ `${width}x${height}`,
15166
+ "-draw_mouse",
15167
+ "0",
15168
+ "-i",
15169
+ DEFAULT_DISPLAY,
15170
+ "-c:v",
15171
+ "libx264",
15172
+ "-preset",
15173
+ "ultrafast",
15174
+ "-tune",
15175
+ "zerolatency",
15176
+ "-crf",
15177
+ "23",
15178
+ "-pix_fmt",
15179
+ "yuv420p",
15180
+ "-movflags",
15181
+ "+faststart+frag_keyframe+empty_moov",
15182
+ rawTarget
15183
+ ], { detached: true, stdio: "ignore" });
15184
+ child.unref();
15185
+ if (!child.pid) fail("failed to launch ffmpeg");
15186
+ writeFileSync2(RECORD_PID_FILE, String(child.pid));
15187
+ writeFileSync2(RECORD_PATH_FILE, target);
15188
+ writeFileSync2(RECORD_RAW_PATH_FILE, rawTarget);
15189
+ writeFileSync2(RECORD_STARTED_AT_FILE, String(Date.now()));
15190
+ writeFileSync2(RECORD_FPS_FILE, String(fps));
15191
+ writeFileSync2(RECORD_DIMENSIONS_FILE, JSON.stringify({ width, height }));
15192
+ console.log(target);
15193
+ }
15194
+ async function computerRecordStopCommand() {
15195
+ if (!existsSync2(RECORD_PID_FILE) && !existsSync2(RECORD_PATH_FILE)) fail("no recording in progress");
15196
+ if (existsSync2(RECORD_PID_FILE)) {
15197
+ const pid = parseInt(readFileSync(RECORD_PID_FILE, "utf8").trim(), 10);
15198
+ if (!Number.isFinite(pid)) fail("invalid recording pidfile");
15199
+ try {
15200
+ process.kill(pid, "SIGINT");
15201
+ } catch {
15202
+ }
15203
+ for (let i = 0; i < 30; i++) {
15204
+ try {
15205
+ process.kill(pid, 0);
15206
+ } catch {
15207
+ break;
15208
+ }
15209
+ await new Promise((r) => setTimeout(r, 200));
15210
+ }
15211
+ rmSync2(RECORD_PID_FILE, { force: true });
15212
+ }
15213
+ if (existsSync2(RECORD_PATH_FILE)) {
15214
+ const target = readFileSync(RECORD_PATH_FILE, "utf8").trim();
15215
+ const rawPath = existsSync2(RECORD_RAW_PATH_FILE) ? readFileSync(RECORD_RAW_PATH_FILE, "utf8").trim() : target;
15216
+ const fps = existsSync2(RECORD_FPS_FILE) ? parseInt(readFileSync(RECORD_FPS_FILE, "utf8").trim(), 10) : 60;
15217
+ const size = readRecordingDimensions();
15218
+ const actions = readRecordingActions();
15219
+ if (rawPath !== target) {
15220
+ renderRecording(rawPath, target, actions, Number.isFinite(fps) ? fps : 60, size);
15221
+ rmSync2(rawPath, { force: true });
15222
+ }
15223
+ console.log(target);
15224
+ rmSync2(RECORD_PATH_FILE, { force: true });
15225
+ rmSync2(RECORD_RAW_PATH_FILE, { force: true });
15226
+ rmSync2(RECORD_STARTED_AT_FILE, { force: true });
15227
+ rmSync2(RECORD_FPS_FILE, { force: true });
15228
+ rmSync2(RECORD_DIMENSIONS_FILE, { force: true });
15229
+ rmSync2(RECORD_ACTIONS_FILE, { force: true });
15230
+ }
15231
+ }
15232
+
15233
+ // src/commands/computer/index.ts
14565
15234
  function desktopBridgeStatus() {
14566
- const r = spawnSync("bash", [SERVICES_SCRIPT, "--status-json"], { stdio: "pipe" });
15235
+ const r = spawnSync3("bash", [SERVICES_SCRIPT, "--status-json"], { stdio: "pipe" });
14567
15236
  if (r.status !== 0) return null;
14568
15237
  try {
14569
15238
  return JSON.parse(r.stdout.toString());
@@ -14580,7 +15249,6 @@ function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
14580
15249
  if (pids.length > 0) parts.push(`pid${pids.length === 1 ? "" : "s"} ${pids.join(",")}`);
14581
15250
  return ` (${parts.join(", ")})`;
14582
15251
  }
14583
- var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
14584
15252
  async function lookupDesktopViewerUrl() {
14585
15253
  try {
14586
15254
  const list = await listAgentPreviews();
@@ -14614,7 +15282,7 @@ async function computerStatusCommand() {
14614
15282
  const bridge = desktopBridgeStatus();
14615
15283
  const procs = ["Xvfb", "openbox", "tint2", "x11vnc", "websockify"];
14616
15284
  for (const p of procs) {
14617
- const r = spawnSync("pgrep", ["-af", p], { stdio: "pipe" });
15285
+ const r = spawnSync3("pgrep", ["-af", p], { stdio: "pipe" });
14618
15286
  const running = r.status === 0 && !!r.stdout?.toString().trim();
14619
15287
  const suffix = p === "x11vnc" && bridge ? bridgeStatus(bridge.x11vnc, true) : p === "websockify" && bridge ? bridgeStatus(bridge.websockify, false, true) : "";
14620
15288
  console.log(` ${running ? chalk20.green("\u25CF") : chalk20.red("\u25CB")} ${p}${suffix}`);
@@ -14646,12 +15314,12 @@ function brandSvgPath() {
14646
15314
  }
14647
15315
  function loadBrandSvg(canvasW, canvasH) {
14648
15316
  const path6 = brandSvgPath();
14649
- if (!existsSync(path6)) {
15317
+ if (!existsSync3(path6)) {
14650
15318
  fail(
14651
15319
  `Brand wallpaper SVG missing at ${path6}. The workspace image is out of date \u2014 \`desktop/brand-wallpaper.svg\` must be installed at $REPLICAS_DESKTOP_TEMPLATES.`
14652
15320
  );
14653
15321
  }
14654
- return readFileSync(path6, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
15322
+ return readFileSync2(path6, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
14655
15323
  }
14656
15324
  var BRAND_PAD_FRACTION = 0.06;
14657
15325
  var SCREENSHOT_CORNER_FRACTION = 0.022;
@@ -14686,8 +15354,8 @@ ${labels.join("\n")}
14686
15354
  </svg>`;
14687
15355
  }
14688
15356
  function overlayGrid(rawPath, target, width, height, gridSize, gridPath) {
14689
- writeFileSync(gridPath, buildGridSvg(width, height, gridSize));
14690
- const r = spawnSync(
15357
+ writeFileSync3(gridPath, buildGridSvg(width, height, gridSize));
15358
+ const r = spawnSync3(
14691
15359
  "ffmpeg",
14692
15360
  [
14693
15361
  "-y",
@@ -14714,7 +15382,7 @@ function overlayGrid(rawPath, target, width, height, gridSize, gridPath) {
14714
15382
  }
14715
15383
  async function computerScreenshotCommand(path6, options = {}) {
14716
15384
  const target = resolvePath(path6);
14717
- mkdirSync(dirname(target), { recursive: true });
15385
+ mkdirSync2(dirname2(target), { recursive: true });
14718
15386
  const stamp = `${process.pid}-${Date.now()}`;
14719
15387
  const rawPath = `/tmp/replicas-screenshot-${stamp}.raw.png`;
14720
15388
  const svgPath = `/tmp/replicas-screenshot-${stamp}.brand.svg`;
@@ -14726,7 +15394,7 @@ async function computerScreenshotCommand(path6, options = {}) {
14726
15394
  const { width, height } = readPngDimensions(rawPath);
14727
15395
  const gridSize = parseGridSize(options.grid);
14728
15396
  if (options.raw && gridSize === null) {
14729
- copyFileSync(rawPath, target);
15397
+ copyFileSync2(rawPath, target);
14730
15398
  console.log(`${target} (${width}x${height}, raw 1:1 desktop pixels)`);
14731
15399
  return;
14732
15400
  }
@@ -14746,16 +15414,16 @@ async function computerScreenshotCommand(path6, options = {}) {
14746
15414
  const shadowMargin = shadowSigma * 3;
14747
15415
  const shadowW = width + shadowMargin * 2;
14748
15416
  const shadowH = height + shadowMargin * 2;
14749
- writeFileSync(svgPath, loadBrandSvg(canvasW, canvasH));
14750
- writeFileSync(
15417
+ writeFileSync3(svgPath, loadBrandSvg(canvasW, canvasH));
15418
+ writeFileSync3(
14751
15419
  maskPath,
14752
15420
  SCREENSHOT_MASK_TEMPLATE.replace(/__W__/g, String(width)).replace(/__H__/g, String(height)).replace(/__R__/g, String(cornerR))
14753
15421
  );
14754
- writeFileSync(
15422
+ writeFileSync3(
14755
15423
  shadowPath,
14756
15424
  SHADOW_MASK_TEMPLATE.replace(/__SW__/g, String(shadowW)).replace(/__SH__/g, String(shadowH)).replace(/__M__/g, String(shadowMargin)).replace(/__W__/g, String(width)).replace(/__H__/g, String(height)).replace(/__R__/g, String(cornerR))
14757
15425
  );
14758
- const r = spawnSync(
15426
+ const r = spawnSync3(
14759
15427
  "ffmpeg",
14760
15428
  [
14761
15429
  "-y",
@@ -14784,16 +15452,16 @@ async function computerScreenshotCommand(path6, options = {}) {
14784
15452
  fail(`ffmpeg branding failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
14785
15453
  }
14786
15454
  } finally {
14787
- rmSync(rawPath, { force: true });
14788
- rmSync(svgPath, { force: true });
14789
- rmSync(maskPath, { force: true });
14790
- rmSync(shadowPath, { force: true });
14791
- rmSync(gridPath, { force: true });
15455
+ rmSync3(rawPath, { force: true });
15456
+ rmSync3(svgPath, { force: true });
15457
+ rmSync3(maskPath, { force: true });
15458
+ rmSync3(shadowPath, { force: true });
15459
+ rmSync3(gridPath, { force: true });
14792
15460
  }
14793
15461
  console.log(target);
14794
15462
  }
14795
15463
  function hashFile(path6) {
14796
- return createHash2("sha256").update(readFileSync(path6)).digest("hex");
15464
+ return createHash2("sha256").update(readFileSync2(path6)).digest("hex");
14797
15465
  }
14798
15466
  async function captureStableRawScreenshot(target, options) {
14799
15467
  const start = Date.now();
@@ -14820,7 +15488,7 @@ async function captureStableRawScreenshot(target, options) {
14820
15488
  lastChangeAt = now;
14821
15489
  }
14822
15490
  lastHash = hash;
14823
- copyFileSync(framePath, target);
15491
+ copyFileSync2(framePath, target);
14824
15492
  if (frames > 1 && now - lastChangeAt >= options.stableMs) {
14825
15493
  return { width, height, stable: true, elapsedMs: now - start, frames, changes };
14826
15494
  }
@@ -14828,26 +15496,9 @@ async function captureStableRawScreenshot(target, options) {
14828
15496
  }
14829
15497
  return { width, height, stable: false, elapsedMs: Date.now() - start, frames, changes };
14830
15498
  } finally {
14831
- rmSync(framePath, { force: true });
15499
+ rmSync3(framePath, { force: true });
14832
15500
  }
14833
15501
  }
14834
- function getMouseLocation() {
14835
- const out = tryDisplayCmd("xdotool", ["getmouselocation", "--shell"]);
14836
- if (!out) return null;
14837
- const values = Object.fromEntries(
14838
- out.split("\n").map((line) => {
14839
- const [key, value] = line.split("=");
14840
- return [key.toLowerCase(), Number.parseInt(value, 10)];
14841
- })
14842
- );
14843
- if (!Number.isFinite(values.x) || !Number.isFinite(values.y)) return null;
14844
- return {
14845
- x: values.x,
14846
- y: values.y,
14847
- screen: Number.isFinite(values.screen) ? values.screen : 0,
14848
- window: Number.isFinite(values.window) ? values.window : 0
14849
- };
14850
- }
14851
15502
  function getActiveWindowTitle() {
14852
15503
  return tryDisplayCmd("xdotool", ["getactivewindow", "getwindowname"]);
14853
15504
  }
@@ -14855,9 +15506,13 @@ function getVisibleWindowTitles() {
14855
15506
  const out = tryDisplayCmd("xdotool", ["search", "--onlyvisible", "--name", ".", "getwindowname", "%@"]);
14856
15507
  return out ? out.split("\n").filter(Boolean).slice(0, 20) : [];
14857
15508
  }
15509
+ function recordingMousePosition() {
15510
+ const mouse = getMouseLocation();
15511
+ return mouse ? { x: mouse.x, y: mouse.y } : null;
15512
+ }
14858
15513
  async function computerObserveCommand(path6, options = {}) {
14859
15514
  const target = resolvePath(path6);
14860
- mkdirSync(dirname(target), { recursive: true });
15515
+ mkdirSync2(dirname2(target), { recursive: true });
14861
15516
  const timeoutMs = options.timeout ? parseCoord(options.timeout, "--timeout") : 3e3;
14862
15517
  const stableMs = options.stableMs ? parseCoord(options.stableMs, "--stable-ms") : 600;
14863
15518
  const pollMs = options.pollMs ? parseCoord(options.pollMs, "--poll-ms") : 200;
@@ -14871,7 +15526,7 @@ async function computerObserveCommand(path6, options = {}) {
14871
15526
  const capture = await captureStableRawScreenshot(rawPath, { timeoutMs, stableMs, pollMs });
14872
15527
  const gridSize = options.raw ? null : parseGridSize(options.grid ?? true);
14873
15528
  if (gridSize === null) {
14874
- copyFileSync(rawPath, target);
15529
+ copyFileSync2(rawPath, target);
14875
15530
  } else {
14876
15531
  overlayGrid(rawPath, target, capture.width, capture.height, gridSize, gridPath);
14877
15532
  }
@@ -14890,8 +15545,8 @@ async function computerObserveCommand(path6, options = {}) {
14890
15545
  gridSize
14891
15546
  }, null, 2));
14892
15547
  } finally {
14893
- rmSync(rawPath, { force: true });
14894
- rmSync(gridPath, { force: true });
15548
+ rmSync3(rawPath, { force: true });
15549
+ rmSync3(gridPath, { force: true });
14895
15550
  }
14896
15551
  }
14897
15552
  async function fetchChromeJson(path6) {
@@ -15017,7 +15672,7 @@ async function evaluateChromeTarget(webSocketDebuggerUrl, expression) {
15017
15672
  }));
15018
15673
  });
15019
15674
  ws.addEventListener("message", (event) => {
15020
- const data = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf8");
15675
+ const data = typeof event.data === "string" ? event.data : Buffer.isBuffer(event.data) ? event.data.toString("utf8") : Buffer.from(event.data).toString("utf8");
15021
15676
  let parsed;
15022
15677
  try {
15023
15678
  parsed = JSON.parse(data);
@@ -15342,6 +15997,7 @@ async function computerClickCommand(xStr, yStr, options) {
15342
15997
  }
15343
15998
  }
15344
15999
  runDisplayCmd("xdotool", args);
16000
+ logRecordingAction({ type: "click", x, y });
15345
16001
  console.log(`clicked ${button === "1" ? "left" : button === "2" ? "middle" : button === "3" ? "right" : `button ${button}`} at (${x},${y})${options.double ? " x2" : ""}`);
15346
16002
  }
15347
16003
  async function computerMoveCommand(xStr, yStr) {
@@ -15349,15 +16005,20 @@ async function computerMoveCommand(xStr, yStr) {
15349
16005
  const x = parseScreenCoord(xStr, "x", dimensions.width);
15350
16006
  const y = parseScreenCoord(yStr, "y", dimensions.height);
15351
16007
  runDisplayCmd("xdotool", ["mousemove", "--sync", String(x), String(y)]);
16008
+ logRecordingAction({ type: "move", x, y });
15352
16009
  console.log(`moved to (${x},${y})`);
15353
16010
  }
15354
16011
  async function computerTypeCommand(text, options) {
15355
16012
  const delay = options.delay ? parseCoord(options.delay, "--delay") : 12;
15356
16013
  runDisplayCmd("xdotool", ["type", "--delay", String(delay), "--", text]);
16014
+ const position = recordingMousePosition();
16015
+ logRecordingAction({ type: "type", ...position ?? {} });
15357
16016
  console.log(`typed ${text.length} char${text.length === 1 ? "" : "s"}`);
15358
16017
  }
15359
16018
  async function computerKeyCommand(combo) {
15360
16019
  runDisplayCmd("xdotool", ["key", "--", combo]);
16020
+ const position = recordingMousePosition();
16021
+ logRecordingAction({ type: "key", ...position ?? {} });
15361
16022
  console.log(`pressed ${combo}`);
15362
16023
  }
15363
16024
  async function computerScrollCommand(direction, options) {
@@ -15367,17 +16028,24 @@ async function computerScrollCommand(direction, options) {
15367
16028
  if (!button) fail(`direction must be one of up|down|left|right (got "${direction}")`);
15368
16029
  const amount = options.amount ? parseCoord(options.amount, "--amount") : 3;
15369
16030
  const args = [];
16031
+ let hoverPosition = null;
15370
16032
  if (options.x && options.y) {
15371
16033
  const dimensions = getDisplayDimensions();
16034
+ hoverPosition = {
16035
+ x: parseScreenCoord(options.x, "--x", dimensions.width),
16036
+ y: parseScreenCoord(options.y, "--y", dimensions.height)
16037
+ };
15372
16038
  args.push(
15373
16039
  "mousemove",
15374
16040
  "--sync",
15375
- String(parseScreenCoord(options.x, "--x", dimensions.width)),
15376
- String(parseScreenCoord(options.y, "--y", dimensions.height))
16041
+ String(hoverPosition.x),
16042
+ String(hoverPosition.y)
15377
16043
  );
15378
16044
  }
15379
16045
  args.push("click", "--repeat", String(amount), "--delay", "30", button);
15380
16046
  runDisplayCmd("xdotool", args);
16047
+ const position = hoverPosition ?? recordingMousePosition();
16048
+ logRecordingAction({ type: "scroll", ...position ?? {} });
15381
16049
  console.log(`scrolled ${dir} x${amount}`);
15382
16050
  }
15383
16051
  async function computerDragCommand(fx, fy, tx, ty) {
@@ -15386,20 +16054,24 @@ async function computerDragCommand(fx, fy, tx, ty) {
15386
16054
  const fromY = parseScreenCoord(fy, "fromY", dimensions.height);
15387
16055
  const toX = parseScreenCoord(tx, "toX", dimensions.width);
15388
16056
  const toY = parseScreenCoord(ty, "toY", dimensions.height);
15389
- runDisplayCmd("xdotool", [
16057
+ const distance = Math.hypot(toX - fromX, toY - fromY);
16058
+ const steps = clamp(Math.ceil(distance / 70), 8, 24);
16059
+ const args = [
15390
16060
  "mousemove",
15391
16061
  "--sync",
15392
16062
  String(fromX),
15393
16063
  String(fromY),
15394
16064
  "mousedown",
15395
- "1",
15396
- "mousemove",
15397
- "--sync",
15398
- String(toX),
15399
- String(toY),
15400
- "mouseup",
15401
16065
  "1"
15402
- ]);
16066
+ ];
16067
+ for (let i = 1; i <= steps; i++) {
16068
+ const x = Math.round(fromX + (toX - fromX) * i / steps);
16069
+ const y = Math.round(fromY + (toY - fromY) * i / steps);
16070
+ args.push("mousemove", "--sync", String(x), String(y), "sleep", "0.018");
16071
+ }
16072
+ args.push("mouseup", "1");
16073
+ runDisplayCmd("xdotool", args);
16074
+ logRecordingAction({ type: "drag", x: fromX, y: fromY, toX, toY });
15403
16075
  console.log(`dragged (${fromX},${fromY}) -> (${toX},${toY})`);
15404
16076
  }
15405
16077
  var APP_ALIASES = {
@@ -15422,10 +16094,10 @@ var LEGACY_CHROME_ALIAS = [
15422
16094
  ];
15423
16095
  async function computerLaunchCommand(app, args) {
15424
16096
  ensureServicesRunning();
15425
- const baseArgs = app === "chrome" && !existsSync(CHROME_WRAPPER) ? LEGACY_CHROME_ALIAS : APP_ALIASES[app] ?? [app];
16097
+ const baseArgs = app === "chrome" && !existsSync3(CHROME_WRAPPER) ? LEGACY_CHROME_ALIAS : APP_ALIASES[app] ?? [app];
15426
16098
  const bin = baseArgs[0];
15427
16099
  const fullArgs = [...baseArgs.slice(1), ...args];
15428
- const child = spawn3(bin, fullArgs, {
16100
+ const child = spawn4(bin, fullArgs, {
15429
16101
  env: withDisplay(),
15430
16102
  detached: true,
15431
16103
  stdio: "ignore"
@@ -15433,85 +16105,6 @@ async function computerLaunchCommand(app, args) {
15433
16105
  child.unref();
15434
16106
  console.log(`launched ${bin} (pid ${child.pid})`);
15435
16107
  }
15436
- var RECORD_PID_FILE = `${STATE_DIR}/ffmpeg.pid`;
15437
- async function computerRecordStartCommand(path6, options) {
15438
- ensureServicesRunning();
15439
- if (existsSync(RECORD_PID_FILE)) {
15440
- const pid = parseInt(readFileSync(RECORD_PID_FILE, "utf8").trim(), 10);
15441
- if (Number.isFinite(pid)) {
15442
- let alive = false;
15443
- try {
15444
- process.kill(pid, 0);
15445
- alive = true;
15446
- } catch {
15447
- }
15448
- if (alive) fail(`recording already in progress (pid ${pid}). run \`replicas computer record stop\` first.`);
15449
- }
15450
- }
15451
- const target = resolvePath(path6);
15452
- mkdirSync(dirname(target), { recursive: true });
15453
- const fps = options.fps ? parseCoord(options.fps, "--fps") : 60;
15454
- const width = process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH);
15455
- const height = process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT);
15456
- const child = spawn3("ffmpeg", [
15457
- "-y",
15458
- "-hide_banner",
15459
- "-loglevel",
15460
- "warning",
15461
- "-f",
15462
- "x11grab",
15463
- "-framerate",
15464
- String(fps),
15465
- "-video_size",
15466
- `${width}x${height}`,
15467
- "-draw_mouse",
15468
- "1",
15469
- "-i",
15470
- DEFAULT_DISPLAY,
15471
- "-c:v",
15472
- "libx264",
15473
- "-preset",
15474
- "ultrafast",
15475
- "-tune",
15476
- "zerolatency",
15477
- "-crf",
15478
- "23",
15479
- "-pix_fmt",
15480
- "yuv420p",
15481
- "-movflags",
15482
- "+faststart+frag_keyframe+empty_moov",
15483
- target
15484
- ], { detached: true, stdio: "ignore" });
15485
- child.unref();
15486
- if (!child.pid) fail("failed to launch ffmpeg");
15487
- mkdirSync(STATE_DIR, { recursive: true });
15488
- writeFileSync(RECORD_PID_FILE, String(child.pid));
15489
- writeFileSync(`${STATE_DIR}/recording-path.txt`, target);
15490
- console.log(target);
15491
- }
15492
- async function computerRecordStopCommand() {
15493
- if (!existsSync(RECORD_PID_FILE)) fail("no recording in progress");
15494
- const pid = parseInt(readFileSync(RECORD_PID_FILE, "utf8").trim(), 10);
15495
- if (!Number.isFinite(pid)) fail("invalid recording pidfile");
15496
- try {
15497
- process.kill(pid, "SIGINT");
15498
- } catch {
15499
- }
15500
- for (let i = 0; i < 30; i++) {
15501
- try {
15502
- process.kill(pid, 0);
15503
- } catch {
15504
- break;
15505
- }
15506
- await new Promise((r) => setTimeout(r, 200));
15507
- }
15508
- rmSync(RECORD_PID_FILE, { force: true });
15509
- const pathFile = `${STATE_DIR}/recording-path.txt`;
15510
- if (existsSync(pathFile)) {
15511
- console.log(readFileSync(pathFile, "utf8").trim());
15512
- rmSync(pathFile, { force: true });
15513
- }
15514
- }
15515
16108
 
15516
16109
  // src/commands/interactive.ts
15517
16110
  import chalk21 from "chalk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-cli",
3
- "version": "0.2.328",
3
+ "version": "0.2.330",
4
4
  "description": "CLI for managing Replicas workspaces - SSH into cloud dev environments with automatic port forwarding",
5
5
  "main": "dist/index.mjs",
6
6
  "bin": {