replicas-computer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,21 +4,30 @@
4
4
  import { createConnection as createConnection2 } from "net";
5
5
 
6
6
  // src/daemon.ts
7
- import { spawnSync as spawnSync4 } from "child_process";
8
- import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync4, rmSync as rmSync4 } from "fs";
7
+ import { spawnSync as spawnSync5 } from "child_process";
8
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync5, rmSync as rmSync4 } from "fs";
9
9
  import { createConnection, createServer } from "net";
10
- import { dirname as dirname4 } from "path";
10
+ import { dirname as dirname5 } from "path";
11
11
 
12
12
  // src/commands/desktop.ts
13
13
  import { spawnSync } from "child_process";
14
14
  import { existsSync, mkdirSync, readFileSync } from "fs";
15
15
  import { dirname, isAbsolute, resolve } from "path";
16
- var DESKTOP_NOVNC_PORT = 6080;
16
+
17
+ // ../shared/src/engine/environment.ts
18
+ var DESKTOP_STREAM_PORT = 6080;
19
+ function getDesktopViewerUrl(publicUrl) {
20
+ const url = new URL(`${publicUrl.replace(/\/$/, "")}/`);
21
+ url.searchParams.set("v", "14");
22
+ return url.toString();
23
+ }
17
24
  var DESKTOP_VIEWER_WIDTH = 1920;
18
25
  var DESKTOP_VIEWER_HEIGHT = 1080;
26
+
27
+ // src/commands/desktop.ts
19
28
  var STATE_DIR = process.env.REPLICAS_DESKTOP_STATE_DIR || "/tmp/replicas-computer";
20
29
  var DEFAULT_DISPLAY = process.env.REPLICAS_DESKTOP_DISPLAY || ":99";
21
- var NOVNC_PORT = process.env.REPLICAS_DESKTOP_NOVNC_PORT ? parseInt(process.env.REPLICAS_DESKTOP_NOVNC_PORT, 10) : DESKTOP_NOVNC_PORT;
30
+ var STREAM_PORT = process.env.REPLICAS_DESKTOP_STREAM_PORT ? parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT, 10) : DESKTOP_STREAM_PORT;
22
31
  var SERVICES_SCRIPT = process.env.REPLICAS_COMPUTER_SERVICES_SCRIPT || "/usr/local/bin/replicas-start-desktop-services";
23
32
  var INPUT_LOCK_FILE = process.env.REPLICAS_DESKTOP_INPUT_LOCK_FILE || `${STATE_DIR}/input.lock`;
24
33
  var CHROME_WRAPPER = "/usr/local/bin/replicas-chrome";
@@ -28,11 +37,11 @@ var lastDesktopHealthAt = 0;
28
37
  var CHROME_DEBUG_PORT = (() => {
29
38
  const value = process.env.REPLICAS_DESKTOP_CHROME_DEBUG_PORT;
30
39
  if (!value) return 9222;
31
- const port = parseInt(value, 10);
32
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
40
+ const port2 = parseInt(value, 10);
41
+ if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535) {
33
42
  fail(`REPLICAS_DESKTOP_CHROME_DEBUG_PORT must be a valid TCP port, got ${JSON.stringify(value)}`);
34
43
  }
35
- return port;
44
+ return port2;
36
45
  })();
37
46
  function fail(msg) {
38
47
  throw new Error(msg);
@@ -48,7 +57,7 @@ function getDesktopBridgeStatus() {
48
57
  }
49
58
  function desktopStackHealthy() {
50
59
  const pids = {};
51
- for (const name of ["openbox", "tint2", "x11vnc", "novnc"]) {
60
+ for (const name of ["openbox", "tint2", "stream"]) {
52
61
  try {
53
62
  const pid = Number.parseInt(readFileSync(`${STATE_DIR}/${name}.pid`, "utf8").trim(), 10);
54
63
  if (!Number.isFinite(pid)) return false;
@@ -59,15 +68,16 @@ function desktopStackHealthy() {
59
68
  }
60
69
  }
61
70
  if (spawnSync("xdpyinfo", [], { env: withDisplay(), stdio: "ignore" }).status !== 0) return false;
71
+ if (spawnSync("pactl", ["info"], { stdio: "ignore" }).status !== 0) return false;
62
72
  const wm = spawnSync("xprop", ["-root", "_NET_SUPPORTING_WM_CHECK"], { env: withDisplay(), stdio: "pipe" });
63
73
  if (wm.status !== 0 || !wm.stdout?.toString().includes("window id")) return false;
64
74
  const bridge = getDesktopBridgeStatus();
65
- return !!bridge?.x11vnc.listenerPids?.includes(pids.x11vnc) && !!bridge.websockify.listenerPids?.includes(pids.novnc);
75
+ return !!bridge?.stream.listenerPids?.includes(pids.stream) && !!bridge.stream.health;
66
76
  }
67
77
  function ensureServicesRunning() {
68
78
  if (!existsSync(SERVICES_SCRIPT)) {
69
79
  fail(
70
- `Desktop services script missing at ${SERVICES_SCRIPT}. The workspace image is out of date \u2014 Xvfb / openbox / x11vnc / websockify must be installed and \`replicas-computer setup\` must install the desktop runtime.`
80
+ `Desktop services script missing at ${SERVICES_SCRIPT}. The workspace image is out of date \u2014 Xvfb / openbox / PulseAudio / ffmpeg must be installed and \`replicas-computer setup\` must install the desktop runtime.`
71
81
  );
72
82
  }
73
83
  if (Date.now() - lastDesktopHealthAt < 1e3) return;
@@ -156,37 +166,293 @@ function resolvePath(p) {
156
166
  }
157
167
  function configuredDesktopDimensions() {
158
168
  const resolution = process.env.REPLICAS_DESKTOP_RESOLUTION?.match(/^(\d+)x(\d+)$/);
159
- const width = parseInt(resolution?.[1] || process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
160
- const height = parseInt(resolution?.[2] || process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
161
- if (!Number.isFinite(width) || width <= 0) fail(`REPLICAS_DESKTOP_WIDTH must be a positive integer`);
162
- if (!Number.isFinite(height) || height <= 0) fail(`REPLICAS_DESKTOP_HEIGHT must be a positive integer`);
163
- return { width, height };
169
+ const width2 = parseInt(resolution?.[1] || process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
170
+ const height2 = parseInt(resolution?.[2] || process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
171
+ if (!Number.isFinite(width2) || width2 <= 0) fail(`REPLICAS_DESKTOP_WIDTH must be a positive integer`);
172
+ if (!Number.isFinite(height2) || height2 <= 0) fail(`REPLICAS_DESKTOP_HEIGHT must be a positive integer`);
173
+ return { width: width2, height: height2 };
164
174
  }
165
175
  function clamp(n, min, max) {
166
176
  return Math.min(max, Math.max(min, n));
167
177
  }
168
- var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
178
+ var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
169
179
 
170
180
  // src/program.ts
171
181
  import { format } from "util";
172
182
  import { Command, CommanderError } from "commander";
173
183
 
174
- // src/commands/index.ts
175
- import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
184
+ // src/commands/browser.ts
185
+ import { spawn as spawn2 } from "child_process";
186
+ import { createHash as createHash2 } from "crypto";
187
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
188
+ import { dirname as dirname4 } from "path";
189
+
190
+ // src/commands/capture.ts
191
+ import { spawnSync as spawnSync2 } from "child_process";
176
192
  import { createHash } from "crypto";
177
- import { closeSync, copyFileSync as copyFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync3, readSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
178
- import { homedir } from "os";
179
- import { dirname as dirname3, join } from "path";
180
- import chalk from "chalk";
193
+ import { closeSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, readSync, rmSync, writeFileSync } from "fs";
194
+ import { dirname as dirname2 } from "path";
195
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
196
+ function readPngDimensions(filePath) {
197
+ const fd = openSync(filePath, "r");
198
+ try {
199
+ const buf = Buffer.alloc(24);
200
+ const bytesRead = readSync(fd, buf, 0, 24, 0);
201
+ if (bytesRead < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
202
+ fail(`${filePath} is not a valid PNG (read ${bytesRead}/24 bytes)`);
203
+ }
204
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
205
+ } finally {
206
+ closeSync(fd);
207
+ }
208
+ }
209
+ function brandSvgPath() {
210
+ const dir = process.env.REPLICAS_DESKTOP_TEMPLATES || "/usr/local/share/replicas/desktop";
211
+ return `${dir}/brand-wallpaper.svg`;
212
+ }
213
+ function loadBrandSvg(canvasW, canvasH) {
214
+ const path = brandSvgPath();
215
+ if (!existsSync2(path)) {
216
+ fail(
217
+ `Brand wallpaper SVG missing at ${path}. The workspace image is out of date \u2014 \`replicas-computer setup\` must install brand-wallpaper.svg at $REPLICAS_DESKTOP_TEMPLATES.`
218
+ );
219
+ }
220
+ return readFileSync2(path, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
221
+ }
222
+ var BRAND_PAD_FRACTION = 0.06;
223
+ var SCREENSHOT_CORNER_FRACTION = 0.022;
224
+ var SHADOW_SIGMA_FRACTION = 0.022;
225
+ var SHADOW_OFFSET_Y_FRACTION = 0.013;
226
+ var SHADOW_ALPHA = 0.6;
227
+ var SCREENSHOT_MASK_TEMPLATE = `<svg width="__W__" height="__H__" xmlns="http://www.w3.org/2000/svg"><rect width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
228
+ var SHADOW_MASK_TEMPLATE = `<svg width="__SW__" height="__SH__" xmlns="http://www.w3.org/2000/svg"><rect x="__M__" y="__M__" width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
229
+ function parseGridSize(value) {
230
+ if (value === void 0 || value === false) return null;
231
+ const size = value === true ? 100 : parseCoord(value, "--grid");
232
+ if (size < 25 || size > 500) fail("--grid must be between 25 and 500 pixels");
233
+ return size;
234
+ }
235
+ function buildGridSvg(width2, height2, size) {
236
+ const lines = [];
237
+ const labels = [];
238
+ for (let x = 0; x <= width2; x += size) {
239
+ const strokeWidth = x % (size * 5) === 0 ? 2 : 1;
240
+ lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height2}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
241
+ if (x > 0 && x < width2) labels.push(`<text x="${x + 4}" y="18">${x}</text>`);
242
+ }
243
+ for (let y = 0; y <= height2; y += size) {
244
+ const strokeWidth = y % (size * 5) === 0 ? 2 : 1;
245
+ lines.push(`<line x1="0" y1="${y}" x2="${width2}" y2="${y}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
246
+ if (y > 0 && y < height2) labels.push(`<text x="4" y="${y - 4}">${y}</text>`);
247
+ }
248
+ return `<svg width="${width2}" height="${height2}" xmlns="http://www.w3.org/2000/svg">
249
+ <style>text{font:16px monospace;fill:white;paint-order:stroke;stroke:black;stroke-width:4px;stroke-linejoin:round}</style>
250
+ ${lines.join("\n")}
251
+ ${labels.join("\n")}
252
+ </svg>`;
253
+ }
254
+ function overlayGrid(rawPath, target, width2, height2, gridSize, gridPath) {
255
+ writeFileSync(gridPath, buildGridSvg(width2, height2, gridSize));
256
+ const r = spawnSync2(
257
+ "ffmpeg",
258
+ [
259
+ "-y",
260
+ "-hide_banner",
261
+ "-loglevel",
262
+ "error",
263
+ "-i",
264
+ rawPath,
265
+ "-i",
266
+ gridPath,
267
+ "-filter_complex",
268
+ "[0:v][1:v]overlay=0:0:format=auto",
269
+ "-frames:v",
270
+ "1",
271
+ "-update",
272
+ "1",
273
+ target
274
+ ],
275
+ { stdio: "pipe" }
276
+ );
277
+ if (r.status !== 0) {
278
+ fail(`ffmpeg grid overlay failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
279
+ }
280
+ }
281
+ async function computerScreenshotCommand(path, options = {}) {
282
+ const target = resolvePath(path);
283
+ mkdirSync2(dirname2(target), { recursive: true });
284
+ const stamp = `${process.pid}-${Date.now()}`;
285
+ const rawPath = `/tmp/replicas-screenshot-${stamp}.raw.png`;
286
+ const svgPath = `/tmp/replicas-screenshot-${stamp}.brand.svg`;
287
+ const maskPath = `/tmp/replicas-screenshot-${stamp}.mask.svg`;
288
+ const shadowPath = `/tmp/replicas-screenshot-${stamp}.shadow.svg`;
289
+ const gridPath = `/tmp/replicas-screenshot-${stamp}.grid.svg`;
290
+ try {
291
+ runDisplayCmd("scrot", ["-o", rawPath]);
292
+ const { width: width2, height: height2 } = readPngDimensions(rawPath);
293
+ const gridSize = parseGridSize(options.grid);
294
+ if (options.raw && gridSize === null) {
295
+ copyFileSync(rawPath, target);
296
+ console.log(`${target} (${width2}x${height2}, raw 1:1 desktop pixels)`);
297
+ return;
298
+ }
299
+ if (gridSize !== null) {
300
+ overlayGrid(rawPath, target, width2, height2, gridSize, gridPath);
301
+ console.log(`${target} (${width2}x${height2}, raw 1:1 desktop pixels, ${gridSize}px grid)`);
302
+ return;
303
+ }
304
+ const padX = Math.round(width2 * BRAND_PAD_FRACTION);
305
+ const padY = Math.round(height2 * BRAND_PAD_FRACTION);
306
+ const canvasW = width2 + padX * 2;
307
+ const canvasH = height2 + padY * 2;
308
+ const minDim = Math.min(width2, height2);
309
+ const cornerR = Math.round(minDim * SCREENSHOT_CORNER_FRACTION);
310
+ const shadowSigma = Math.max(12, Math.round(minDim * SHADOW_SIGMA_FRACTION));
311
+ const shadowOffsetY = Math.round(minDim * SHADOW_OFFSET_Y_FRACTION);
312
+ const shadowMargin = shadowSigma * 3;
313
+ const shadowW = width2 + shadowMargin * 2;
314
+ const shadowH = height2 + shadowMargin * 2;
315
+ writeFileSync(svgPath, loadBrandSvg(canvasW, canvasH));
316
+ writeFileSync(
317
+ maskPath,
318
+ SCREENSHOT_MASK_TEMPLATE.replace(/__W__/g, String(width2)).replace(/__H__/g, String(height2)).replace(/__R__/g, String(cornerR))
319
+ );
320
+ writeFileSync(
321
+ shadowPath,
322
+ SHADOW_MASK_TEMPLATE.replace(/__SW__/g, String(shadowW)).replace(/__SH__/g, String(shadowH)).replace(/__M__/g, String(shadowMargin)).replace(/__W__/g, String(width2)).replace(/__H__/g, String(height2)).replace(/__R__/g, String(cornerR))
323
+ );
324
+ const r = spawnSync2(
325
+ "ffmpeg",
326
+ [
327
+ "-y",
328
+ "-hide_banner",
329
+ "-loglevel",
330
+ "error",
331
+ "-i",
332
+ svgPath,
333
+ "-i",
334
+ rawPath,
335
+ "-i",
336
+ maskPath,
337
+ "-i",
338
+ shadowPath,
339
+ "-filter_complex",
340
+ `[2:v]format=rgba,alphaextract[mask];[3:v]format=rgba,colorchannelmixer=rr=0:gg=0:bb=0:aa=${SHADOW_ALPHA},gblur=sigma=${shadowSigma}[shadow];[1:v]format=rgba[scr];[scr][mask]alphamerge[rounded];[0:v]format=rgba[bg];[bg][shadow]overlay=${padX - shadowMargin}:${padY - shadowMargin + shadowOffsetY}:format=auto[bg_shadow];[bg_shadow][rounded]overlay=${padX}:${padY}:format=auto`,
341
+ "-frames:v",
342
+ "1",
343
+ "-update",
344
+ "1",
345
+ target
346
+ ],
347
+ { stdio: "pipe" }
348
+ );
349
+ if (r.status !== 0) {
350
+ fail(`ffmpeg branding failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
351
+ }
352
+ } finally {
353
+ rmSync(rawPath, { force: true });
354
+ rmSync(svgPath, { force: true });
355
+ rmSync(maskPath, { force: true });
356
+ rmSync(shadowPath, { force: true });
357
+ rmSync(gridPath, { force: true });
358
+ }
359
+ console.log(target);
360
+ }
361
+ function hashFile(path) {
362
+ return createHash("sha256").update(readFileSync2(path)).digest("hex");
363
+ }
364
+ async function captureStableRawScreenshot(target, options) {
365
+ const start = Date.now();
366
+ const framePath = `${target}.frame.png`;
367
+ let lastHash = null;
368
+ let lastChangeAt = start;
369
+ let frames = 0;
370
+ let changes = 0;
371
+ let width2 = 0;
372
+ let height2 = 0;
373
+ try {
374
+ while (Date.now() - start <= options.timeoutMs) {
375
+ runDisplayCmd("scrot", ["-o", framePath]);
376
+ frames++;
377
+ const dimensions = readPngDimensions(framePath);
378
+ width2 = dimensions.width;
379
+ height2 = dimensions.height;
380
+ const hash = hashFile(framePath);
381
+ const now = Date.now();
382
+ if (lastHash === null) {
383
+ lastChangeAt = now;
384
+ } else if (hash !== lastHash) {
385
+ changes++;
386
+ lastChangeAt = now;
387
+ }
388
+ lastHash = hash;
389
+ copyFileSync(framePath, target);
390
+ if (frames > 1 && now - lastChangeAt >= options.stableMs) {
391
+ return { width: width2, height: height2, stable: true, elapsedMs: now - start, frames, changes };
392
+ }
393
+ await sleep(options.pollMs);
394
+ }
395
+ return { width: width2, height: height2, stable: false, elapsedMs: Date.now() - start, frames, changes };
396
+ } finally {
397
+ rmSync(framePath, { force: true });
398
+ }
399
+ }
400
+ function getActiveWindowTitle() {
401
+ return tryDisplayCmd("xdotool", ["getactivewindow", "getwindowname"]);
402
+ }
403
+ function getVisibleWindowTitles() {
404
+ const out = tryDisplayCmd("xdotool", ["search", "--onlyvisible", "--name", ".", "getwindowname", "%@"]);
405
+ return out ? out.split("\n").filter(Boolean).slice(0, 20) : [];
406
+ }
407
+ async function computerObserveCommand(path, options = {}) {
408
+ const target = resolvePath(path);
409
+ mkdirSync2(dirname2(target), { recursive: true });
410
+ const timeoutMs = options.timeout ? parseCoord(options.timeout, "--timeout") : 3e3;
411
+ const stableMs = options.stableMs ? parseCoord(options.stableMs, "--stable-ms") : 600;
412
+ const pollMs = options.pollMs ? parseCoord(options.pollMs, "--poll-ms") : 200;
413
+ if (timeoutMs < 0) fail("--timeout must be >= 0");
414
+ if (stableMs < 0) fail("--stable-ms must be >= 0");
415
+ if (pollMs < 50 || pollMs > 2e3) fail("--poll-ms must be between 50 and 2000");
416
+ const stamp = `${process.pid}-${Date.now()}`;
417
+ const rawPath = `/tmp/replicas-observe-${stamp}.raw.png`;
418
+ const gridPath = `/tmp/replicas-observe-${stamp}.grid.svg`;
419
+ try {
420
+ ensureServicesRunning();
421
+ const capture = await captureStableRawScreenshot(rawPath, { timeoutMs, stableMs, pollMs });
422
+ const gridSize = options.raw ? null : parseGridSize(options.grid ?? true);
423
+ if (gridSize === null) {
424
+ copyFileSync(rawPath, target);
425
+ } else {
426
+ overlayGrid(rawPath, target, capture.width, capture.height, gridSize, gridPath);
427
+ }
428
+ console.log(JSON.stringify({
429
+ screenshot: target,
430
+ width: capture.width,
431
+ height: capture.height,
432
+ stable: capture.stable,
433
+ elapsedMs: capture.elapsedMs,
434
+ frames: capture.frames,
435
+ changes: capture.changes,
436
+ mouse: getMouseLocation(),
437
+ activeWindow: getActiveWindowTitle(),
438
+ visibleWindows: getVisibleWindowTitles(),
439
+ mode: gridSize === null ? "raw" : "grid",
440
+ gridSize
441
+ }, null, 2));
442
+ } finally {
443
+ rmSync(rawPath, { force: true });
444
+ rmSync(gridPath, { force: true });
445
+ }
446
+ }
181
447
 
182
448
  // src/commands/recording.ts
183
449
  import { spawn } from "child_process";
184
- import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
185
- import { dirname as dirname2 } from "path";
450
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
451
+ import { dirname as dirname3 } from "path";
186
452
 
187
453
  // src/commands/recording/render.ts
188
- import { spawnSync as spawnSync2 } from "child_process";
189
- import { copyFileSync, rmSync, writeFileSync } from "fs";
454
+ import { spawnSync as spawnSync3 } from "child_process";
455
+ import { copyFileSync as copyFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
190
456
 
191
457
  // src/commands/recording/config.ts
192
458
  var cameraMotion = {
@@ -500,7 +766,7 @@ function buildCursorOverlayFilter(actions, duration, size, assets, projectCursor
500
766
 
501
767
  // src/commands/recording/render.ts
502
768
  function videoDuration(path) {
503
- const r = spawnSync2("ffprobe", [
769
+ const r = spawnSync3("ffprobe", [
504
770
  "-v",
505
771
  "error",
506
772
  "-show_entries",
@@ -578,20 +844,20 @@ function projectedCursorExpression(rawCursor, axis, segments, spans, size) {
578
844
  }
579
845
  return expression;
580
846
  }
581
- function renderRecording(rawPath, target, actions, fps, size) {
847
+ function renderRecording(rawPath, target, actions, fps2, size) {
582
848
  if (actions.length === 0) {
583
- copyFileSync(rawPath, target);
849
+ copyFileSync2(rawPath, target);
584
850
  return;
585
851
  }
586
852
  const duration = videoDuration(rawPath);
587
853
  const segments = buildRecordingSegments(actions, duration, size);
588
854
  if (segments.length === 0) {
589
- copyFileSync(rawPath, target);
855
+ copyFileSync2(rawPath, target);
590
856
  return;
591
857
  }
592
858
  const stamp = `${process.pid}-${Date.now()}`;
593
859
  const cursor = cursorAssets(stamp);
594
- writeFileSync(cursor.path, cursorSvg(cursor.size));
860
+ writeFileSync2(cursor.path, cursorSvg(cursor.size));
595
861
  const spans = renderedSegmentSpans(segments);
596
862
  const renderedDuration = spans.length ? spans[spans.length - 1].outputEnd : duration;
597
863
  const renderedActions = actionsOnRenderedTimeline(actions, spans);
@@ -614,9 +880,9 @@ function renderRecording(rawPath, target, actions, fps, size) {
614
880
  const concatInputs = segments.map((_, index) => `[v${index}]`).join("");
615
881
  const filter = `${screenSplitFilter};${filters.join(";")};${concatInputs}concat=n=${segments.length}:v=1:a=0[screen];${cursorFilter}`;
616
882
  const filterPath = `/tmp/replicas-recording-filter-${stamp}.ffgraph`;
617
- writeFileSync(filterPath, filter);
883
+ writeFileSync2(filterPath, filter);
618
884
  try {
619
- const r = spawnSync2("ffmpeg", [
885
+ const r = spawnSync3("ffmpeg", [
620
886
  "-y",
621
887
  "-hide_banner",
622
888
  "-loglevel",
@@ -632,7 +898,7 @@ function renderRecording(rawPath, target, actions, fps, size) {
632
898
  "-map",
633
899
  "[v]",
634
900
  "-r",
635
- String(fps),
901
+ String(fps2),
636
902
  "-c:v",
637
903
  "libx264",
638
904
  "-preset",
@@ -649,8 +915,8 @@ function renderRecording(rawPath, target, actions, fps, size) {
649
915
  fail(`recording post-processing failed: ${r.error?.message || r.stderr?.toString().trim() || `exit ${r.status}`}`);
650
916
  }
651
917
  } finally {
652
- rmSync(filterPath, { force: true });
653
- rmSync(cursor.path, { force: true });
918
+ rmSync2(filterPath, { force: true });
919
+ rmSync2(cursor.path, { force: true });
654
920
  }
655
921
  }
656
922
 
@@ -672,11 +938,11 @@ var RECORD_STATE_FILES = [
672
938
  RECORD_ACTIONS_FILE
673
939
  ];
674
940
  function clearRecordingState() {
675
- for (const file of RECORD_STATE_FILES) rmSync2(file, { force: true });
941
+ for (const file of RECORD_STATE_FILES) rmSync3(file, { force: true });
676
942
  }
677
943
  function recordingStartedAt() {
678
- if (!existsSync2(RECORD_STARTED_AT_FILE)) return null;
679
- const startedAt = Number.parseInt(readFileSync2(RECORD_STARTED_AT_FILE, "utf8").trim(), 10);
944
+ if (!existsSync3(RECORD_STARTED_AT_FILE)) return null;
945
+ const startedAt = Number.parseInt(readFileSync3(RECORD_STARTED_AT_FILE, "utf8").trim(), 10);
680
946
  return Number.isFinite(startedAt) ? startedAt : null;
681
947
  }
682
948
  function logRecordingAction(action) {
@@ -687,13 +953,13 @@ function logRecordingAction(action) {
687
953
  `);
688
954
  }
689
955
  function readRecordingDimensions() {
690
- if (!existsSync2(RECORD_DIMENSIONS_FILE)) return configuredDesktopDimensions();
956
+ if (!existsSync3(RECORD_DIMENSIONS_FILE)) return configuredDesktopDimensions();
691
957
  try {
692
- const dimensions = JSON.parse(readFileSync2(RECORD_DIMENSIONS_FILE, "utf8"));
693
- const width = dimensions?.width;
694
- const height = dimensions?.height;
695
- if (typeof width === "number" && Number.isFinite(width) && width > 0 && typeof height === "number" && Number.isFinite(height) && height > 0) {
696
- return { width, height };
958
+ const dimensions = JSON.parse(readFileSync3(RECORD_DIMENSIONS_FILE, "utf8"));
959
+ const width2 = dimensions?.width;
960
+ const height2 = dimensions?.height;
961
+ if (typeof width2 === "number" && Number.isFinite(width2) && width2 > 0 && typeof height2 === "number" && Number.isFinite(height2) && height2 > 0) {
962
+ return { width: width2, height: height2 };
697
963
  }
698
964
  } catch {
699
965
  }
@@ -706,8 +972,8 @@ function isOptionalNumber(value) {
706
972
  return value === void 0 || typeof value === "number";
707
973
  }
708
974
  function readRecordingActions() {
709
- if (!existsSync2(RECORD_ACTIONS_FILE)) return [];
710
- return readFileSync2(RECORD_ACTIONS_FILE, "utf8").split("\n").filter(Boolean).flatMap((line) => {
975
+ if (!existsSync3(RECORD_ACTIONS_FILE)) return [];
976
+ return readFileSync3(RECORD_ACTIONS_FILE, "utf8").split("\n").filter(Boolean).flatMap((line) => {
711
977
  try {
712
978
  const value = JSON.parse(line);
713
979
  if (typeof value !== "object" || value === null) return [];
@@ -728,8 +994,8 @@ function readRecordingActions() {
728
994
  }
729
995
  async function computerRecordStartCommand(path, options) {
730
996
  ensureServicesRunning();
731
- if (existsSync2(RECORD_PID_FILE)) {
732
- const pid = parseInt(readFileSync2(RECORD_PID_FILE, "utf8").trim(), 10);
997
+ if (existsSync3(RECORD_PID_FILE)) {
998
+ const pid = parseInt(readFileSync3(RECORD_PID_FILE, "utf8").trim(), 10);
733
999
  if (Number.isFinite(pid)) {
734
1000
  let alive2 = false;
735
1001
  try {
@@ -741,12 +1007,12 @@ async function computerRecordStartCommand(path, options) {
741
1007
  }
742
1008
  }
743
1009
  const target = resolvePath(path);
744
- mkdirSync2(dirname2(target), { recursive: true });
745
- const fps = options.fps ? parseCoord(options.fps, "--fps") : 60;
746
- const { width, height } = configuredDesktopDimensions();
747
- mkdirSync2(STATE_DIR, { recursive: true });
1010
+ mkdirSync3(dirname3(target), { recursive: true });
1011
+ const fps2 = options.fps ? parseCoord(options.fps, "--fps") : 60;
1012
+ const { width: width2, height: height2 } = configuredDesktopDimensions();
1013
+ mkdirSync3(STATE_DIR, { recursive: true });
748
1014
  const rawTarget = `${target}.raw-${Date.now()}.mp4`;
749
- rmSync2(RECORD_ACTIONS_FILE, { force: true });
1015
+ rmSync3(RECORD_ACTIONS_FILE, { force: true });
750
1016
  const child = spawn("ffmpeg", [
751
1017
  "-y",
752
1018
  "-hide_banner",
@@ -755,9 +1021,9 @@ async function computerRecordStartCommand(path, options) {
755
1021
  "-f",
756
1022
  "x11grab",
757
1023
  "-framerate",
758
- String(fps),
1024
+ String(fps2),
759
1025
  "-video_size",
760
- `${width}x${height}`,
1026
+ `${width2}x${height2}`,
761
1027
  "-draw_mouse",
762
1028
  "0",
763
1029
  "-i",
@@ -778,17 +1044,17 @@ async function computerRecordStartCommand(path, options) {
778
1044
  ], { detached: true, stdio: "ignore" });
779
1045
  child.unref();
780
1046
  if (!child.pid) fail("failed to launch ffmpeg");
781
- writeFileSync2(RECORD_PID_FILE, String(child.pid));
782
- writeFileSync2(RECORD_PATH_FILE, target);
783
- writeFileSync2(RECORD_RAW_PATH_FILE, rawTarget);
784
- writeFileSync2(RECORD_STARTED_AT_FILE, String(Date.now()));
785
- writeFileSync2(RECORD_FPS_FILE, String(fps));
786
- writeFileSync2(RECORD_DIMENSIONS_FILE, JSON.stringify({ width, height }));
1047
+ writeFileSync3(RECORD_PID_FILE, String(child.pid));
1048
+ writeFileSync3(RECORD_PATH_FILE, target);
1049
+ writeFileSync3(RECORD_RAW_PATH_FILE, rawTarget);
1050
+ writeFileSync3(RECORD_STARTED_AT_FILE, String(Date.now()));
1051
+ writeFileSync3(RECORD_FPS_FILE, String(fps2));
1052
+ writeFileSync3(RECORD_DIMENSIONS_FILE, JSON.stringify({ width: width2, height: height2 }));
787
1053
  const startedAt = Date.now();
788
1054
  while (Date.now() - startedAt < 5e3) {
789
1055
  try {
790
1056
  process.kill(child.pid, 0);
791
- if (existsSync2(rawTarget) && statSync(rawTarget).size > 0) {
1057
+ if (existsSync3(rawTarget) && statSync(rawTarget).size > 0) {
792
1058
  console.log(`${target} (recording ready in ${Date.now() - startedAt}ms)`);
793
1059
  return;
794
1060
  }
@@ -805,13 +1071,13 @@ async function computerRecordStartCommand(path, options) {
805
1071
  }
806
1072
  if (alive) fail("ffmpeg is running but did not produce recording output within 5 seconds; run `replicas computer record stop` to finalize or retry");
807
1073
  clearRecordingState();
808
- rmSync2(rawTarget, { force: true });
1074
+ rmSync3(rawTarget, { force: true });
809
1075
  fail("ffmpeg exited before screen recording became ready");
810
1076
  }
811
1077
  async function computerRecordStopCommand() {
812
- if (!existsSync2(RECORD_PID_FILE) && !existsSync2(RECORD_PATH_FILE)) fail("no recording in progress");
813
- if (existsSync2(RECORD_PID_FILE)) {
814
- const pid = parseInt(readFileSync2(RECORD_PID_FILE, "utf8").trim(), 10);
1078
+ if (!existsSync3(RECORD_PID_FILE) && !existsSync3(RECORD_PATH_FILE)) fail("no recording in progress");
1079
+ if (existsSync3(RECORD_PID_FILE)) {
1080
+ const pid = parseInt(readFileSync3(RECORD_PID_FILE, "utf8").trim(), 10);
815
1081
  if (!Number.isFinite(pid)) fail("invalid recording pidfile");
816
1082
  try {
817
1083
  process.kill(pid, "SIGINT");
@@ -829,351 +1095,35 @@ async function computerRecordStopCommand() {
829
1095
  }
830
1096
  if (alive) fail(`ffmpeg did not finalize recording within 30 seconds (pid ${pid})`);
831
1097
  }
832
- if (existsSync2(RECORD_PATH_FILE)) {
833
- const target = readFileSync2(RECORD_PATH_FILE, "utf8").trim();
834
- const rawPath = existsSync2(RECORD_RAW_PATH_FILE) ? readFileSync2(RECORD_RAW_PATH_FILE, "utf8").trim() : target;
835
- const fps = existsSync2(RECORD_FPS_FILE) ? parseInt(readFileSync2(RECORD_FPS_FILE, "utf8").trim(), 10) : 60;
1098
+ if (existsSync3(RECORD_PATH_FILE)) {
1099
+ const target = readFileSync3(RECORD_PATH_FILE, "utf8").trim();
1100
+ const rawPath = existsSync3(RECORD_RAW_PATH_FILE) ? readFileSync3(RECORD_RAW_PATH_FILE, "utf8").trim() : target;
1101
+ const fps2 = existsSync3(RECORD_FPS_FILE) ? parseInt(readFileSync3(RECORD_FPS_FILE, "utf8").trim(), 10) : 60;
836
1102
  const size = readRecordingDimensions();
837
1103
  const actions = readRecordingActions();
838
1104
  if (rawPath !== target) {
839
- renderRecording(rawPath, target, actions, Number.isFinite(fps) ? fps : 60, size);
840
- rmSync2(rawPath, { force: true });
1105
+ renderRecording(rawPath, target, actions, Number.isFinite(fps2) ? fps2 : 60, size);
1106
+ rmSync3(rawPath, { force: true });
841
1107
  }
842
1108
  console.log(target);
843
1109
  }
844
1110
  clearRecordingState();
845
1111
  }
846
1112
 
847
- // src/commands/index.ts
1113
+ // ../shared/src/type-guards.ts
848
1114
  function isRecord(value) {
849
1115
  return typeof value === "object" && value !== null && !Array.isArray(value);
850
1116
  }
851
- function getDesktopViewerUrl(publicUrl) {
852
- const url = new URL(`${publicUrl.replace(/\/$/, "")}/`);
853
- url.searchParams.set("v", "13");
854
- return url.toString();
855
- }
856
- function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
857
- const pids = rootsOnly ? details.rootPids ?? [] : details.listenerPids ?? [];
858
- const listenerCount = rootsOnly ? details.rootListeners ?? pids.length : details.listeners ?? pids.length;
859
- const listenerLabel = rootsOnly ? "root listener" : "listener";
860
- const parts = [`${listenerCount} ${listenerLabel}${listenerCount === 1 ? "" : "s"} on ${details.port}`];
861
- if (includeBacklog) parts.push(`backlog ${details.backlog ?? 0}`);
862
- if (pids.length > 0) parts.push(`pid${pids.length === 1 ? "" : "s"} ${pids.join(",")}`);
863
- return ` (${parts.join(", ")})`;
864
- }
865
- async function lookupDesktopViewerUrl() {
1117
+
1118
+ // src/commands/browser.ts
1119
+ async function fetchChromeJson(path) {
1120
+ let res;
866
1121
  try {
867
- const value = JSON.parse(readFileSync3(join(homedir(), ".replicas", "preview-ports.json"), "utf8"));
868
- if (!isRecord(value) || !Array.isArray(value.previews)) return null;
869
- const preview = value.previews.find(
870
- (item) => isRecord(item) && item.port === NOVNC_PORT && typeof item.publicUrl === "string"
871
- );
872
- return isRecord(preview) && typeof preview.publicUrl === "string" ? getDesktopViewerUrl(preview.publicUrl) : null;
1122
+ res = await fetch(`http://127.0.0.1:${CHROME_DEBUG_PORT}${path}`);
873
1123
  } catch {
874
- return null;
875
- }
876
- }
877
- async function waitForDesktopViewerUrl(timeoutMs) {
878
- const deadline = Date.now() + timeoutMs;
879
- while (Date.now() < deadline) {
880
- const url = await lookupDesktopViewerUrl();
881
- if (url) return url;
882
- await new Promise((resolve3) => setTimeout(resolve3, INFO_WAIT_INTERVAL_MS));
883
- }
884
- return await lookupDesktopViewerUrl();
885
- }
886
- async function computerInfoCommand() {
887
- const viewerUrl = await waitForDesktopViewerUrl(INFO_WAIT_TIMEOUT_MS);
888
- if (!viewerUrl) {
889
- fail(
890
- `Desktop preview for port ${NOVNC_PORT} is not registered. The engine registers it at startup \u2014 check /tmp/replicas-desktop-stack.log and engine logs for errors.`
891
- );
892
- }
893
- console.log(viewerUrl);
894
- console.error(chalk.dim(`Share this URL with the user to let them watch the desktop live.`));
895
- }
896
- async function computerStatusCommand() {
897
- ensureServicesRunning();
898
- const bridge = getDesktopBridgeStatus();
899
- const procs = ["Xvfb", "openbox", "tint2", "x11vnc", "websockify"];
900
- for (const p of procs) {
901
- const r = spawnSync3("pgrep", ["-af", p], { stdio: "pipe" });
902
- const running = r.status === 0 && !!r.stdout?.toString().trim();
903
- const suffix = p === "x11vnc" && bridge ? bridgeStatus(bridge.x11vnc, true) : p === "websockify" && bridge ? bridgeStatus(bridge.websockify, false, true) : "";
904
- console.log(` ${running ? chalk.green("\u25CF") : chalk.red("\u25CB")} ${p}${suffix}`);
905
- }
906
- const viewerUrl = await lookupDesktopViewerUrl();
907
- if (viewerUrl) {
908
- console.log(` ${chalk.cyan("preview")}: ${viewerUrl}`);
909
- } else {
910
- console.log(` ${chalk.dim("preview: not yet registered (engine registers it at startup)")}`);
911
- }
912
- }
913
- var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
914
- function readPngDimensions(filePath) {
915
- const fd = openSync(filePath, "r");
916
- try {
917
- const buf = Buffer.alloc(24);
918
- const bytesRead = readSync(fd, buf, 0, 24, 0);
919
- if (bytesRead < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
920
- fail(`${filePath} is not a valid PNG (read ${bytesRead}/24 bytes)`);
921
- }
922
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
923
- } finally {
924
- closeSync(fd);
925
- }
926
- }
927
- function brandSvgPath() {
928
- const dir = process.env.REPLICAS_DESKTOP_TEMPLATES || "/usr/local/share/replicas/desktop";
929
- return `${dir}/brand-wallpaper.svg`;
930
- }
931
- function loadBrandSvg(canvasW, canvasH) {
932
- const path = brandSvgPath();
933
- if (!existsSync3(path)) {
934
- fail(
935
- `Brand wallpaper SVG missing at ${path}. The workspace image is out of date \u2014 \`replicas-computer setup\` must install brand-wallpaper.svg at $REPLICAS_DESKTOP_TEMPLATES.`
936
- );
937
- }
938
- return readFileSync3(path, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
939
- }
940
- var BRAND_PAD_FRACTION = 0.06;
941
- var SCREENSHOT_CORNER_FRACTION = 0.022;
942
- var SHADOW_SIGMA_FRACTION = 0.022;
943
- var SHADOW_OFFSET_Y_FRACTION = 0.013;
944
- var SHADOW_ALPHA = 0.6;
945
- var SCREENSHOT_MASK_TEMPLATE = `<svg width="__W__" height="__H__" xmlns="http://www.w3.org/2000/svg"><rect width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
946
- var SHADOW_MASK_TEMPLATE = `<svg width="__SW__" height="__SH__" xmlns="http://www.w3.org/2000/svg"><rect x="__M__" y="__M__" width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
947
- function parseGridSize(value) {
948
- if (value === void 0 || value === false) return null;
949
- const size = value === true ? 100 : parseCoord(value, "--grid");
950
- if (size < 25 || size > 500) fail("--grid must be between 25 and 500 pixels");
951
- return size;
952
- }
953
- function buildGridSvg(width, height, size) {
954
- const lines = [];
955
- const labels = [];
956
- for (let x = 0; x <= width; x += size) {
957
- const strokeWidth = x % (size * 5) === 0 ? 2 : 1;
958
- lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
959
- if (x > 0 && x < width) labels.push(`<text x="${x + 4}" y="18">${x}</text>`);
960
- }
961
- for (let y = 0; y <= height; y += size) {
962
- const strokeWidth = y % (size * 5) === 0 ? 2 : 1;
963
- lines.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
964
- if (y > 0 && y < height) labels.push(`<text x="4" y="${y - 4}">${y}</text>`);
965
- }
966
- return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
967
- <style>text{font:16px monospace;fill:white;paint-order:stroke;stroke:black;stroke-width:4px;stroke-linejoin:round}</style>
968
- ${lines.join("\n")}
969
- ${labels.join("\n")}
970
- </svg>`;
971
- }
972
- function overlayGrid(rawPath, target, width, height, gridSize, gridPath) {
973
- writeFileSync3(gridPath, buildGridSvg(width, height, gridSize));
974
- const r = spawnSync3(
975
- "ffmpeg",
976
- [
977
- "-y",
978
- "-hide_banner",
979
- "-loglevel",
980
- "error",
981
- "-i",
982
- rawPath,
983
- "-i",
984
- gridPath,
985
- "-filter_complex",
986
- "[0:v][1:v]overlay=0:0:format=auto",
987
- "-frames:v",
988
- "1",
989
- "-update",
990
- "1",
991
- target
992
- ],
993
- { stdio: "pipe" }
994
- );
995
- if (r.status !== 0) {
996
- fail(`ffmpeg grid overlay failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
997
- }
998
- }
999
- async function computerScreenshotCommand(path, options = {}) {
1000
- const target = resolvePath(path);
1001
- mkdirSync3(dirname3(target), { recursive: true });
1002
- const stamp = `${process.pid}-${Date.now()}`;
1003
- const rawPath = `/tmp/replicas-screenshot-${stamp}.raw.png`;
1004
- const svgPath = `/tmp/replicas-screenshot-${stamp}.brand.svg`;
1005
- const maskPath = `/tmp/replicas-screenshot-${stamp}.mask.svg`;
1006
- const shadowPath = `/tmp/replicas-screenshot-${stamp}.shadow.svg`;
1007
- const gridPath = `/tmp/replicas-screenshot-${stamp}.grid.svg`;
1008
- try {
1009
- runDisplayCmd("scrot", ["-o", rawPath]);
1010
- const { width, height } = readPngDimensions(rawPath);
1011
- const gridSize = parseGridSize(options.grid);
1012
- if (options.raw && gridSize === null) {
1013
- copyFileSync2(rawPath, target);
1014
- console.log(`${target} (${width}x${height}, raw 1:1 desktop pixels)`);
1015
- return;
1016
- }
1017
- if (gridSize !== null) {
1018
- overlayGrid(rawPath, target, width, height, gridSize, gridPath);
1019
- console.log(`${target} (${width}x${height}, raw 1:1 desktop pixels, ${gridSize}px grid)`);
1020
- return;
1021
- }
1022
- const padX = Math.round(width * BRAND_PAD_FRACTION);
1023
- const padY = Math.round(height * BRAND_PAD_FRACTION);
1024
- const canvasW = width + padX * 2;
1025
- const canvasH = height + padY * 2;
1026
- const minDim = Math.min(width, height);
1027
- const cornerR = Math.round(minDim * SCREENSHOT_CORNER_FRACTION);
1028
- const shadowSigma = Math.max(12, Math.round(minDim * SHADOW_SIGMA_FRACTION));
1029
- const shadowOffsetY = Math.round(minDim * SHADOW_OFFSET_Y_FRACTION);
1030
- const shadowMargin = shadowSigma * 3;
1031
- const shadowW = width + shadowMargin * 2;
1032
- const shadowH = height + shadowMargin * 2;
1033
- writeFileSync3(svgPath, loadBrandSvg(canvasW, canvasH));
1034
- writeFileSync3(
1035
- maskPath,
1036
- SCREENSHOT_MASK_TEMPLATE.replace(/__W__/g, String(width)).replace(/__H__/g, String(height)).replace(/__R__/g, String(cornerR))
1037
- );
1038
- writeFileSync3(
1039
- shadowPath,
1040
- 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))
1041
- );
1042
- const r = spawnSync3(
1043
- "ffmpeg",
1044
- [
1045
- "-y",
1046
- "-hide_banner",
1047
- "-loglevel",
1048
- "error",
1049
- "-i",
1050
- svgPath,
1051
- "-i",
1052
- rawPath,
1053
- "-i",
1054
- maskPath,
1055
- "-i",
1056
- shadowPath,
1057
- "-filter_complex",
1058
- `[2:v]format=rgba,alphaextract[mask];[3:v]format=rgba,colorchannelmixer=rr=0:gg=0:bb=0:aa=${SHADOW_ALPHA},gblur=sigma=${shadowSigma}[shadow];[1:v]format=rgba[scr];[scr][mask]alphamerge[rounded];[0:v]format=rgba[bg];[bg][shadow]overlay=${padX - shadowMargin}:${padY - shadowMargin + shadowOffsetY}:format=auto[bg_shadow];[bg_shadow][rounded]overlay=${padX}:${padY}:format=auto`,
1059
- "-frames:v",
1060
- "1",
1061
- "-update",
1062
- "1",
1063
- target
1064
- ],
1065
- { stdio: "pipe" }
1066
- );
1067
- if (r.status !== 0) {
1068
- fail(`ffmpeg branding failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
1069
- }
1070
- } finally {
1071
- rmSync3(rawPath, { force: true });
1072
- rmSync3(svgPath, { force: true });
1073
- rmSync3(maskPath, { force: true });
1074
- rmSync3(shadowPath, { force: true });
1075
- rmSync3(gridPath, { force: true });
1076
- }
1077
- console.log(target);
1078
- }
1079
- function hashFile(path) {
1080
- return createHash("sha256").update(readFileSync3(path)).digest("hex");
1081
- }
1082
- async function captureStableRawScreenshot(target, options) {
1083
- const start = Date.now();
1084
- const framePath = `${target}.frame.png`;
1085
- let lastHash = null;
1086
- let lastChangeAt = start;
1087
- let frames = 0;
1088
- let changes = 0;
1089
- let width = 0;
1090
- let height = 0;
1091
- try {
1092
- while (Date.now() - start <= options.timeoutMs) {
1093
- runDisplayCmd("scrot", ["-o", framePath]);
1094
- frames++;
1095
- const dimensions = readPngDimensions(framePath);
1096
- width = dimensions.width;
1097
- height = dimensions.height;
1098
- const hash = hashFile(framePath);
1099
- const now = Date.now();
1100
- if (lastHash === null) {
1101
- lastChangeAt = now;
1102
- } else if (hash !== lastHash) {
1103
- changes++;
1104
- lastChangeAt = now;
1105
- }
1106
- lastHash = hash;
1107
- copyFileSync2(framePath, target);
1108
- if (frames > 1 && now - lastChangeAt >= options.stableMs) {
1109
- return { width, height, stable: true, elapsedMs: now - start, frames, changes };
1110
- }
1111
- await sleep(options.pollMs);
1112
- }
1113
- return { width, height, stable: false, elapsedMs: Date.now() - start, frames, changes };
1114
- } finally {
1115
- rmSync3(framePath, { force: true });
1116
- }
1117
- }
1118
- function getActiveWindowTitle() {
1119
- return tryDisplayCmd("xdotool", ["getactivewindow", "getwindowname"]);
1120
- }
1121
- function getVisibleWindowTitles() {
1122
- const out = tryDisplayCmd("xdotool", ["search", "--onlyvisible", "--name", ".", "getwindowname", "%@"]);
1123
- return out ? out.split("\n").filter(Boolean).slice(0, 20) : [];
1124
- }
1125
- function recordingMousePosition() {
1126
- const mouse = getMouseLocation();
1127
- return mouse ? { x: mouse.x, y: mouse.y } : null;
1128
- }
1129
- async function computerObserveCommand(path, options = {}) {
1130
- const target = resolvePath(path);
1131
- mkdirSync3(dirname3(target), { recursive: true });
1132
- const timeoutMs = options.timeout ? parseCoord(options.timeout, "--timeout") : 3e3;
1133
- const stableMs = options.stableMs ? parseCoord(options.stableMs, "--stable-ms") : 600;
1134
- const pollMs = options.pollMs ? parseCoord(options.pollMs, "--poll-ms") : 200;
1135
- if (timeoutMs < 0) fail("--timeout must be >= 0");
1136
- if (stableMs < 0) fail("--stable-ms must be >= 0");
1137
- if (pollMs < 50 || pollMs > 2e3) fail("--poll-ms must be between 50 and 2000");
1138
- const stamp = `${process.pid}-${Date.now()}`;
1139
- const rawPath = `/tmp/replicas-observe-${stamp}.raw.png`;
1140
- const gridPath = `/tmp/replicas-observe-${stamp}.grid.svg`;
1141
- try {
1142
- ensureServicesRunning();
1143
- const capture = await captureStableRawScreenshot(rawPath, { timeoutMs, stableMs, pollMs });
1144
- const gridSize = options.raw ? null : parseGridSize(options.grid ?? true);
1145
- if (gridSize === null) {
1146
- copyFileSync2(rawPath, target);
1147
- } else {
1148
- overlayGrid(rawPath, target, capture.width, capture.height, gridSize, gridPath);
1149
- }
1150
- console.log(JSON.stringify({
1151
- screenshot: target,
1152
- width: capture.width,
1153
- height: capture.height,
1154
- stable: capture.stable,
1155
- elapsedMs: capture.elapsedMs,
1156
- frames: capture.frames,
1157
- changes: capture.changes,
1158
- mouse: getMouseLocation(),
1159
- activeWindow: getActiveWindowTitle(),
1160
- visibleWindows: getVisibleWindowTitles(),
1161
- mode: gridSize === null ? "raw" : "grid",
1162
- gridSize
1163
- }, null, 2));
1164
- } finally {
1165
- rmSync3(rawPath, { force: true });
1166
- rmSync3(gridPath, { force: true });
1167
- }
1168
- }
1169
- async function fetchChromeJson(path) {
1170
- let res;
1171
- try {
1172
- res = await fetch(`http://127.0.0.1:${CHROME_DEBUG_PORT}${path}`);
1173
- } catch {
1174
- fail(
1175
- `Chrome DevTools is not available on localhost:${CHROME_DEBUG_PORT}. Launch Chrome with \`replicas computer launch chrome <url>\` and try again.`
1176
- );
1124
+ fail(
1125
+ `Chrome DevTools is not available on localhost:${CHROME_DEBUG_PORT}. Launch Chrome with \`replicas computer launch chrome <url>\` and try again.`
1126
+ );
1177
1127
  }
1178
1128
  if (!res.ok) fail(`Chrome DevTools returned HTTP ${res.status}`);
1179
1129
  try {
@@ -1224,7 +1174,7 @@ async function sendChromeSessionCommand(session, method, params) {
1224
1174
  return chromeResult(await session.send({ method, params }), method);
1225
1175
  }
1226
1176
  async function withChromeSession(webSocketDebuggerUrl, callback) {
1227
- return await new Promise((resolve3, reject) => {
1177
+ return await new Promise((resolve4, reject) => {
1228
1178
  const ws = new WebSocket(webSocketDebuggerUrl);
1229
1179
  const pending = /* @__PURE__ */ new Map();
1230
1180
  let nextId = 1;
@@ -1246,7 +1196,7 @@ async function withChromeSession(webSocketDebuggerUrl, callback) {
1246
1196
  for (const request of pending.values()) clearTimeout(request.timeout);
1247
1197
  pending.clear();
1248
1198
  ws.close();
1249
- resolve3(value);
1199
+ resolve4(value);
1250
1200
  };
1251
1201
  const session = {
1252
1202
  send: ({ method, params }) => new Promise((resolveCommand, rejectCommand) => {
@@ -1363,7 +1313,7 @@ function browserStateProperties(node) {
1363
1313
  function readBrowserStateCache(path) {
1364
1314
  let value;
1365
1315
  try {
1366
- value = JSON.parse(readFileSync3(path, "utf8"));
1316
+ value = JSON.parse(readFileSync4(path, "utf8"));
1367
1317
  } catch {
1368
1318
  return null;
1369
1319
  }
@@ -1421,9 +1371,9 @@ function browserContentQuadRects(quads, viewportRect) {
1421
1371
  const ys = [quad[1], quad[3], quad[5], quad[7]];
1422
1372
  const x = Math.min(...xs);
1423
1373
  const y = Math.min(...ys);
1424
- const width = Math.max(...xs) - x;
1425
- const height = Math.max(...ys) - y;
1426
- return { x, y, width, height, centerX: x + width / 2, centerY: y + height / 2 };
1374
+ const width2 = Math.max(...xs) - x;
1375
+ const height2 = Math.max(...ys) - y;
1376
+ return { x, y, width: width2, height: height2, centerX: x + width2 / 2, centerY: y + height2 / 2 };
1427
1377
  }).map((rect) => intersectRects(rect, viewportRect)).filter((rect) => !!rect).sort((a, b) => b.width * b.height - a.width * a.height);
1428
1378
  }
1429
1379
  function buildBrowserBounds(result, viewport, targetId) {
@@ -1438,14 +1388,14 @@ function buildBrowserBounds(result, viewport, targetId) {
1438
1388
  nodeIndexes.forEach((nodeIndex, index) => {
1439
1389
  const bound = bounds[index];
1440
1390
  if (!Array.isArray(bound) || bound.length < 4 || !bound.every(Number.isFinite)) return;
1441
- const [rawX, rawY, width, height] = bound;
1391
+ const [rawX, rawY, width2, height2] = bound;
1442
1392
  rects.set(nodeIndex, {
1443
1393
  x: rawX - scrollX,
1444
1394
  y: rawY - scrollY,
1445
- width,
1446
- height,
1447
- centerX: rawX - scrollX + width / 2,
1448
- centerY: rawY - scrollY + height / 2
1395
+ width: width2,
1396
+ height: height2,
1397
+ centerX: rawX - scrollX + width2 / 2,
1398
+ centerY: rawY - scrollY + height2 / 2
1449
1399
  });
1450
1400
  });
1451
1401
  return rects;
@@ -1640,7 +1590,8 @@ async function captureBrowserSnapshot(page, options) {
1640
1590
  node,
1641
1591
  role: roleOf(node),
1642
1592
  bound: typeof node.backendDOMNodeId === "number" ? bounds.get(node.backendDOMNodeId) : void 0
1643
- })).filter(({ node, role, bound }) => {
1593
+ })).filter((entry) => {
1594
+ const { node, role, bound } = entry;
1644
1595
  if (!bound || bound.rect.width <= 0 || bound.rect.height <= 0) return false;
1645
1596
  if (role === "InlineTextBox" || role === "none" || role === "generic") return false;
1646
1597
  if (BROWSER_INTERACTIVE_ROLES.has(role) || BROWSER_STRUCTURAL_ROLES.has(role)) return true;
@@ -1709,7 +1660,7 @@ async function captureBrowserSnapshot(page, options) {
1709
1660
  visibleTextLength += Math.min(name.length, remaining) + 1;
1710
1661
  }
1711
1662
  }
1712
- const revision = createHash("sha256").update(JSON.stringify([page.url, entries.map(({ key, semantic }) => [key, semantic])])).digest("hex").slice(0, 16);
1663
+ const revision = createHash2("sha256").update(JSON.stringify([page.url, entries.map(({ key, semantic }) => [key, semantic])])).digest("hex").slice(0, 16);
1713
1664
  const snapshot = {
1714
1665
  title: (page.title ?? "").slice(0, 1e3),
1715
1666
  url: (page.url ?? "").slice(0, 2e3),
@@ -1889,7 +1840,7 @@ async function computerBrowserStateCommand(path, options = {}) {
1889
1840
  const page = await selectChromePage(options);
1890
1841
  const stability = await waitForBrowserStability(page, { timeoutMs, stableMs, pollMs });
1891
1842
  const target = resolvePath(path);
1892
- mkdirSync3(dirname3(target), { recursive: true });
1843
+ mkdirSync4(dirname4(target), { recursive: true });
1893
1844
  const [{ snapshot, entries }, screenshotResult] = await Promise.all([
1894
1845
  captureBrowserSnapshot(page, { textLimit, elementLimit }),
1895
1846
  sendChromeCommand(page.webSocketDebuggerUrl, "Page.captureScreenshot", {
@@ -1900,14 +1851,14 @@ async function computerBrowserStateCommand(path, options = {}) {
1900
1851
  ]);
1901
1852
  const data = screenshotResult.data;
1902
1853
  if (typeof data !== "string") fail("Chrome did not return screenshot data");
1903
- writeFileSync3(target, Buffer.from(data, "base64"));
1854
+ writeFileSync4(target, Buffer.from(data, "base64"));
1904
1855
  const screenshot = readPngDimensions(target);
1905
1856
  const targetId = page.id ?? "unknown";
1906
1857
  const cachePath = browserStateCachePath(targetId);
1907
1858
  let mode = "full";
1908
1859
  let tree = snapshot.tree;
1909
1860
  let changes = { added: snapshot.nodeCount, changed: 0, removed: 0 };
1910
- if (!options.full && existsSync3(cachePath)) {
1861
+ if (!options.full && existsSync4(cachePath)) {
1911
1862
  const cached = readBrowserStateCache(cachePath);
1912
1863
  if (cached?.url === snapshot.url && cached.documentId === snapshot.documentId) {
1913
1864
  mode = "diff";
@@ -1916,8 +1867,8 @@ async function computerBrowserStateCommand(path, options = {}) {
1916
1867
  changes = { added: diff.added, changed: diff.changed, removed: diff.removed };
1917
1868
  }
1918
1869
  }
1919
- mkdirSync3(STATE_DIR, { recursive: true });
1920
- writeFileSync3(cachePath, JSON.stringify({ url: snapshot.url, documentId: snapshot.documentId, entries }));
1870
+ mkdirSync4(STATE_DIR, { recursive: true });
1871
+ writeFileSync4(cachePath, JSON.stringify({ url: snapshot.url, documentId: snapshot.documentId, entries }));
1921
1872
  const stableState = isRecord(stability.state) ? stability.state : {};
1922
1873
  const state = {
1923
1874
  title: snapshot.title,
@@ -1963,7 +1914,7 @@ function readBrowserRefCache(page) {
1963
1914
  const targetId = page.id;
1964
1915
  if (!targetId) fail("Chrome target has no stable ID. Capture fresh browser state and retry.");
1965
1916
  const cachePath = browserStateCachePath(targetId);
1966
- if (!existsSync3(cachePath)) fail(`No browser state is cached for target ${targetId}. Run replicas computer browser-state <path> --target-id ${targetId} first.`);
1917
+ if (!existsSync4(cachePath)) fail(`No browser state is cached for target ${targetId}. Run replicas computer browser-state <path> --target-id ${targetId} first.`);
1967
1918
  const cached = readBrowserStateCache(cachePath);
1968
1919
  if (!cached) fail(`Browser state for target ${targetId} is invalid. Capture fresh browser state and retry.`);
1969
1920
  if (cached.url !== (page.url ?? "").slice(0, 2e3)) fail("Cached browser state belongs to a different URL. Capture fresh browser state and retry.");
@@ -2718,16 +2669,96 @@ async function computerBrowserBatchCommand(actionsJson, options = {}) {
2718
2669
  }
2719
2670
  console.log(JSON.stringify({ ok: true, targetId, elapsedMs: Date.now() - startedAt, completed }, null, 2));
2720
2671
  }
2721
- async function computerClickCommand(xStr, yStr, options) {
2722
- const dimensions = getDisplayDimensions();
2723
- const x = parseScreenCoord(xStr, "x", dimensions.width);
2724
- const y = parseScreenCoord(yStr, "y", dimensions.height);
2725
- const button = options.button ?? "1";
2726
- const args = ["mousemove", "--sync", String(x), String(y)];
2727
- if (options.modifiers) {
2728
- for (const mod of options.modifiers.split("+")) {
2729
- args.push("keydown", mod);
2730
- }
2672
+ var APP_ALIASES = {
2673
+ chrome: [CHROME_WRAPPER],
2674
+ chromium: ["chromium", "--no-first-run", "--no-default-browser-check"],
2675
+ firefox: ["firefox"],
2676
+ terminal: ["xfce4-terminal"],
2677
+ xterm: ["xterm"],
2678
+ notepad: ["mousepad"],
2679
+ editor: ["mousepad"],
2680
+ files: ["thunar"],
2681
+ filemanager: ["thunar"]
2682
+ };
2683
+ var LEGACY_CHROME_ALIAS = [
2684
+ "google-chrome",
2685
+ "--no-first-run",
2686
+ "--no-default-browser-check",
2687
+ "--start-maximized",
2688
+ "--user-data-dir=/tmp/replicas-computer/chrome-profile"
2689
+ ];
2690
+ async function computerLaunchCommand(app, args) {
2691
+ ensureServicesRunning();
2692
+ const baseArgs = app === "chrome" && !existsSync4(CHROME_WRAPPER) ? LEGACY_CHROME_ALIAS : APP_ALIASES[app] ?? [app];
2693
+ const bin = baseArgs[0];
2694
+ const fullArgs = [...baseArgs.slice(1), ...args];
2695
+ const expectsNewChromePage = args.some((arg) => !arg.startsWith("-"));
2696
+ const existingChromePageIds = /* @__PURE__ */ new Set();
2697
+ if (app === "chrome") {
2698
+ try {
2699
+ for (const page of await getChromePages()) {
2700
+ if (page.id) existingChromePageIds.add(page.id);
2701
+ }
2702
+ } catch {
2703
+ }
2704
+ }
2705
+ const child = spawn2(bin, fullArgs, {
2706
+ env: withDisplay(),
2707
+ detached: true,
2708
+ stdio: "ignore"
2709
+ });
2710
+ child.unref();
2711
+ if (app === "chrome") {
2712
+ const startedAt = Date.now();
2713
+ let pageCount = 0;
2714
+ let readyPage = null;
2715
+ while (Date.now() - startedAt < 15e3) {
2716
+ let pages = [];
2717
+ try {
2718
+ pages = await getChromePages();
2719
+ } catch {
2720
+ }
2721
+ pageCount = pages.length;
2722
+ const candidates = expectsNewChromePage && existingChromePageIds.size > 0 ? pages.filter((page) => page.id && !existingChromePageIds.has(page.id)) : pages;
2723
+ for (const page of candidates) {
2724
+ if (!page.webSocketDebuggerUrl || !page.url || page.url === "about:blank") continue;
2725
+ const readyState = await evaluateChromeTarget(page.webSocketDebuggerUrl, "document.readyState");
2726
+ if (readyState === "interactive" || readyState === "complete") {
2727
+ readyPage = page;
2728
+ break;
2729
+ }
2730
+ }
2731
+ if (readyPage) break;
2732
+ try {
2733
+ if (child.pid) process.kill(child.pid, 0);
2734
+ } catch {
2735
+ fail(`Chrome exited before its desktop control channel became ready.`);
2736
+ }
2737
+ await sleep(100);
2738
+ }
2739
+ if (!readyPage?.webSocketDebuggerUrl) fail(`Chrome launched but its requested page was not controllable after 15 seconds.`);
2740
+ await sendChromeCommand(readyPage.webSocketDebuggerUrl, "Page.bringToFront", {});
2741
+ console.log(`launched ${bin} (pid ${child.pid}, ready in ${Date.now() - startedAt}ms, page ${readyPage.id}, ${pageCount} open)`);
2742
+ return;
2743
+ }
2744
+ console.log(`launched ${bin} (pid ${child.pid})`);
2745
+ }
2746
+
2747
+ // src/commands/input.ts
2748
+ var recordingMousePosition = () => {
2749
+ const mouse = getMouseLocation();
2750
+ return mouse ? { x: mouse.x, y: mouse.y } : null;
2751
+ };
2752
+ async function computerClickCommand(xStr, yStr, options) {
2753
+ const dimensions = getDisplayDimensions();
2754
+ const x = parseScreenCoord(xStr, "x", dimensions.width);
2755
+ const y = parseScreenCoord(yStr, "y", dimensions.height);
2756
+ const button = options.button ?? "1";
2757
+ const args = ["mousemove", "--sync", String(x), String(y)];
2758
+ if (options.modifiers) {
2759
+ for (const mod of options.modifiers.split("+")) {
2760
+ args.push("keydown", mod);
2761
+ }
2731
2762
  }
2732
2763
  if (options.double) {
2733
2764
  args.push("click", "--repeat", "2", "--delay", "50", button);
@@ -2817,83 +2848,80 @@ async function computerDragCommand(fx, fy, tx, ty) {
2817
2848
  logRecordingAction({ type: "drag", x: fromX, y: fromY, toX, toY });
2818
2849
  console.log(`dragged (${fromX},${fromY}) -> (${toX},${toY})`);
2819
2850
  }
2820
- var APP_ALIASES = {
2821
- chrome: [CHROME_WRAPPER],
2822
- chromium: ["chromium", "--no-first-run", "--no-default-browser-check"],
2823
- firefox: ["firefox"],
2824
- terminal: ["xfce4-terminal"],
2825
- xterm: ["xterm"],
2826
- notepad: ["mousepad"],
2827
- editor: ["mousepad"],
2828
- files: ["thunar"],
2829
- filemanager: ["thunar"]
2830
- };
2831
- var LEGACY_CHROME_ALIAS = [
2832
- "google-chrome",
2833
- "--no-first-run",
2834
- "--no-default-browser-check",
2835
- "--start-maximized",
2836
- "--user-data-dir=/tmp/replicas-computer/chrome-profile"
2837
- ];
2838
- async function computerLaunchCommand(app, args) {
2851
+
2852
+ // src/commands/system.ts
2853
+ import { spawnSync as spawnSync4 } from "child_process";
2854
+ import { readFileSync as readFileSync5 } from "fs";
2855
+ import { homedir } from "os";
2856
+ import { join } from "path";
2857
+ import chalk from "chalk";
2858
+ function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
2859
+ const pids = rootsOnly ? details.rootPids ?? [] : details.listenerPids ?? [];
2860
+ const listenerCount = rootsOnly ? details.rootListeners ?? pids.length : details.listeners ?? pids.length;
2861
+ const listenerLabel = rootsOnly ? "root listener" : "listener";
2862
+ const parts = [`${listenerCount} ${listenerLabel}${listenerCount === 1 ? "" : "s"} on ${details.port}`];
2863
+ parts.push(`${details.connections ?? 0} active`);
2864
+ if (includeBacklog) parts.push(`backlog ${details.backlog ?? 0}`);
2865
+ if (pids.length > 0) parts.push(`pid${pids.length === 1 ? "" : "s"} ${pids.join(",")}`);
2866
+ return ` (${parts.join(", ")})`;
2867
+ }
2868
+ async function lookupDesktopViewerUrl() {
2869
+ try {
2870
+ const value = JSON.parse(readFileSync5(join(homedir(), ".replicas", "preview-ports.json"), "utf8"));
2871
+ if (!isRecord(value) || !Array.isArray(value.previews)) return null;
2872
+ const preview = value.previews.find(
2873
+ (item) => isRecord(item) && item.port === STREAM_PORT && typeof item.publicUrl === "string"
2874
+ );
2875
+ return isRecord(preview) && typeof preview.publicUrl === "string" ? getDesktopViewerUrl(preview.publicUrl) : null;
2876
+ } catch {
2877
+ return null;
2878
+ }
2879
+ }
2880
+ async function waitForDesktopViewerUrl(timeoutMs) {
2881
+ const deadline = Date.now() + timeoutMs;
2882
+ while (Date.now() < deadline) {
2883
+ const url = await lookupDesktopViewerUrl();
2884
+ if (url) return url;
2885
+ await new Promise((resolve4) => setTimeout(resolve4, INFO_WAIT_INTERVAL_MS));
2886
+ }
2887
+ return await lookupDesktopViewerUrl();
2888
+ }
2889
+ async function computerInfoCommand() {
2890
+ const viewerUrl = await waitForDesktopViewerUrl(INFO_WAIT_TIMEOUT_MS);
2891
+ if (!viewerUrl) {
2892
+ fail(
2893
+ `Desktop preview for port ${STREAM_PORT} is not registered. The engine registers it at startup \u2014 check /tmp/replicas-desktop-stack.log and engine logs for errors.`
2894
+ );
2895
+ }
2896
+ console.log(viewerUrl);
2897
+ console.error(chalk.dim(`Share this URL with the user to let them watch the desktop live.`));
2898
+ }
2899
+ async function computerStatusCommand() {
2839
2900
  ensureServicesRunning();
2840
- const baseArgs = app === "chrome" && !existsSync3(CHROME_WRAPPER) ? LEGACY_CHROME_ALIAS : APP_ALIASES[app] ?? [app];
2841
- const bin = baseArgs[0];
2842
- const fullArgs = [...baseArgs.slice(1), ...args];
2843
- const expectsNewChromePage = args.some((arg) => !arg.startsWith("-"));
2844
- const existingChromePageIds = /* @__PURE__ */ new Set();
2845
- if (app === "chrome") {
2846
- try {
2847
- for (const page of await getChromePages()) {
2848
- if (page.id) existingChromePageIds.add(page.id);
2849
- }
2850
- } catch {
2851
- }
2901
+ const bridge = getDesktopBridgeStatus();
2902
+ const procs = [
2903
+ ["Xvfb", "Xvfb"],
2904
+ ["openbox", "openbox"],
2905
+ ["tint2", "tint2"],
2906
+ ["PulseAudio", "pulseaudio"],
2907
+ ["desktop stream", "replicas-computer.*stream"]
2908
+ ];
2909
+ for (const [label, pattern] of procs) {
2910
+ const r = spawnSync4("pgrep", ["-af", pattern], { stdio: "pipe" });
2911
+ const running = r.status === 0 && !!r.stdout?.toString().trim();
2912
+ const suffix = label === "desktop stream" && bridge ? bridgeStatus(bridge.stream) : "";
2913
+ console.log(` ${running ? chalk.green("\u25CF") : chalk.red("\u25CB")} ${label}${suffix}`);
2852
2914
  }
2853
- const child = spawn2(bin, fullArgs, {
2854
- env: withDisplay(),
2855
- detached: true,
2856
- stdio: "ignore"
2857
- });
2858
- child.unref();
2859
- if (app === "chrome") {
2860
- const startedAt = Date.now();
2861
- let pageCount = 0;
2862
- let readyPage = null;
2863
- while (Date.now() - startedAt < 15e3) {
2864
- let pages = [];
2865
- try {
2866
- pages = await getChromePages();
2867
- } catch {
2868
- }
2869
- pageCount = pages.length;
2870
- const candidates = expectsNewChromePage && existingChromePageIds.size > 0 ? pages.filter((page) => page.id && !existingChromePageIds.has(page.id)) : pages;
2871
- for (const page of candidates) {
2872
- if (!page.webSocketDebuggerUrl || !page.url || page.url === "about:blank") continue;
2873
- const readyState = await evaluateChromeTarget(page.webSocketDebuggerUrl, "document.readyState");
2874
- if (readyState === "interactive" || readyState === "complete") {
2875
- readyPage = page;
2876
- break;
2877
- }
2878
- }
2879
- if (readyPage) break;
2880
- try {
2881
- if (child.pid) process.kill(child.pid, 0);
2882
- } catch {
2883
- fail(`Chrome exited before its desktop control channel became ready.`);
2884
- }
2885
- await sleep(100);
2886
- }
2887
- if (!readyPage?.webSocketDebuggerUrl) fail(`Chrome launched but its requested page was not controllable after 15 seconds.`);
2888
- await sendChromeCommand(readyPage.webSocketDebuggerUrl, "Page.bringToFront", {});
2889
- console.log(`launched ${bin} (pid ${child.pid}, ready in ${Date.now() - startedAt}ms, page ${readyPage.id}, ${pageCount} open)`);
2890
- return;
2915
+ const viewerUrl = await lookupDesktopViewerUrl();
2916
+ if (viewerUrl) {
2917
+ console.log(` ${chalk.cyan("preview")}: ${viewerUrl}`);
2918
+ } else {
2919
+ console.log(` ${chalk.dim("preview: not yet registered (engine registers it at startup)")}`);
2891
2920
  }
2892
- console.log(`launched ${bin} (pid ${child.pid})`);
2893
2921
  }
2894
2922
 
2895
2923
  // src/version.ts
2896
- var COMPUTER_USE_VERSION = "0.1.0";
2924
+ var COMPUTER_USE_VERSION = "0.2.0";
2897
2925
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2898
2926
 
2899
2927
  // src/program.ts
@@ -2971,6 +2999,8 @@ async function runDirectCommand(argv, cwd = process.cwd()) {
2971
2999
  // src/daemon.ts
2972
3000
  var COMPUTER_USE_SOCKET_PATH = process.env.REPLICAS_COMPUTER_SOCKET || `${STATE_DIR}/control.sock`;
2973
3001
  var MAX_REQUEST_BYTES = 1024 * 1024;
3002
+ var MAX_CLIENTS = 256;
3003
+ var REQUEST_READ_TIMEOUT_MS = 1e4;
2974
3004
  function isDaemonRequest(value) {
2975
3005
  return typeof value === "object" && value !== null && "protocolVersion" in value && value.protocolVersion === COMPUTER_USE_PROTOCOL_VERSION && "cwd" in value && typeof value.cwd === "string" && value.cwd.startsWith("/") && "argv" in value && Array.isArray(value.argv) && value.argv.length <= 100 && value.argv.every((arg) => typeof arg === "string" && arg.length <= 1e5);
2976
3006
  }
@@ -2979,27 +3009,38 @@ function writeResult(socket, result) {
2979
3009
  `);
2980
3010
  }
2981
3011
  async function hasSocketListener() {
2982
- if (!existsSync4(COMPUTER_USE_SOCKET_PATH)) return false;
2983
- return await new Promise((resolve3) => {
3012
+ if (!existsSync5(COMPUTER_USE_SOCKET_PATH)) return false;
3013
+ return await new Promise((resolve4) => {
2984
3014
  const socket = createConnection(COMPUTER_USE_SOCKET_PATH);
2985
3015
  socket.setTimeout(500, () => socket.destroy());
2986
3016
  socket.once("connect", () => {
2987
3017
  socket.destroy();
2988
- resolve3(true);
3018
+ resolve4(true);
2989
3019
  });
2990
- socket.once("error", () => resolve3(false));
2991
- socket.once("close", () => resolve3(false));
3020
+ socket.once("error", () => resolve4(false));
3021
+ socket.once("close", () => resolve4(false));
2992
3022
  });
2993
3023
  }
2994
3024
  async function runComputerDaemon() {
2995
3025
  if (await hasSocketListener()) throw new Error(`Computer-use daemon is already listening on ${COMPUTER_USE_SOCKET_PATH}`);
2996
3026
  ensureServicesRunning();
2997
- mkdirSync4(dirname4(COMPUTER_USE_SOCKET_PATH), { recursive: true, mode: 448 });
3027
+ mkdirSync5(dirname5(COMPUTER_USE_SOCKET_PATH), { recursive: true, mode: 448 });
2998
3028
  rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
2999
3029
  let queue = Promise.resolve();
3030
+ const clients = /* @__PURE__ */ new Set();
3000
3031
  const server = createServer({ allowHalfOpen: true }, (socket) => {
3032
+ if (clients.size >= MAX_CLIENTS) {
3033
+ writeResult(socket, { exitCode: 75, stdout: "", stderr: "Computer-use daemon is busy; retry the request\n" });
3034
+ return;
3035
+ }
3001
3036
  let input = "";
3002
3037
  let rejected = false;
3038
+ clients.add(socket);
3039
+ socket.once("close", () => clients.delete(socket));
3040
+ socket.setTimeout(REQUEST_READ_TIMEOUT_MS, () => {
3041
+ rejected = true;
3042
+ writeResult(socket, { exitCode: 2, stdout: "", stderr: "Computer-use request was not received within 10 seconds\n" });
3043
+ });
3003
3044
  socket.setEncoding("utf8");
3004
3045
  socket.on("data", (chunk) => {
3005
3046
  if (rejected) return;
@@ -3011,6 +3052,7 @@ async function runComputerDaemon() {
3011
3052
  });
3012
3053
  socket.on("end", () => {
3013
3054
  if (rejected) return;
3055
+ socket.setTimeout(0);
3014
3056
  queue = queue.then(async () => {
3015
3057
  let request;
3016
3058
  try {
@@ -3030,33 +3072,35 @@ async function runComputerDaemon() {
3030
3072
  });
3031
3073
  });
3032
3074
  });
3033
- await new Promise((resolve3, reject) => {
3075
+ await new Promise((resolve4, reject) => {
3034
3076
  server.once("error", reject);
3035
3077
  server.listen(COMPUTER_USE_SOCKET_PATH, () => {
3036
3078
  server.off("error", reject);
3037
3079
  chmodSync(COMPUTER_USE_SOCKET_PATH, 384);
3038
3080
  console.log(`replicas-computer ${COMPUTER_USE_VERSION} listening on ${COMPUTER_USE_SOCKET_PATH}`);
3039
- resolve3();
3081
+ resolve4();
3040
3082
  });
3041
3083
  });
3042
3084
  const healthTimer = setInterval(() => {
3043
- queue = queue.then(() => ensureServicesRunning()).catch((error) => {
3085
+ try {
3086
+ ensureServicesRunning();
3087
+ } catch (error) {
3044
3088
  console.error(error instanceof Error ? error.message : String(error));
3045
- });
3089
+ }
3046
3090
  }, 5e3);
3047
3091
  healthTimer.unref();
3048
- await new Promise((resolve3) => {
3092
+ await new Promise((resolve4) => {
3049
3093
  let closing = false;
3050
3094
  const close = () => {
3051
3095
  if (closing) return;
3052
3096
  closing = true;
3053
3097
  clearInterval(healthTimer);
3054
- server.close(() => resolve3());
3098
+ server.close(() => resolve4());
3055
3099
  };
3056
3100
  process.once("SIGTERM", close);
3057
3101
  process.once("SIGINT", close);
3058
3102
  });
3059
- spawnSync4("bash", [SERVICES_SCRIPT, "--stop"], { stdio: "inherit" });
3103
+ spawnSync5("bash", [SERVICES_SCRIPT, "--stop"], { stdio: "inherit" });
3060
3104
  rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
3061
3105
  }
3062
3106
 
@@ -3065,7 +3109,7 @@ var DAEMON_STARTUP_TIMEOUT_MS = 5e3;
3065
3109
  var ComputerDaemonUnavailableError = class extends Error {
3066
3110
  };
3067
3111
  function sendComputerRequest(argv) {
3068
- return new Promise((resolve3, reject) => {
3112
+ return new Promise((resolve4, reject) => {
3069
3113
  const socket = createConnection2(COMPUTER_USE_SOCKET_PATH);
3070
3114
  let response = "";
3071
3115
  let settled = false;
@@ -3073,7 +3117,7 @@ function sendComputerRequest(argv) {
3073
3117
  if (settled) return;
3074
3118
  settled = true;
3075
3119
  if (error) reject(error);
3076
- else if (result) resolve3(result);
3120
+ else if (result) resolve4(result);
3077
3121
  };
3078
3122
  socket.setEncoding("utf8");
3079
3123
  socket.setTimeout(10 * 60 * 1e3, () => socket.destroy(new Error("Computer-use command timed out")));
@@ -3111,16 +3155,645 @@ async function runComputerClient(argv) {
3111
3155
  return await sendComputerRequest(argv);
3112
3156
  } catch (error) {
3113
3157
  if (!(error instanceof ComputerDaemonUnavailableError) || Date.now() >= deadline) throw error;
3114
- await new Promise((resolve3) => setTimeout(resolve3, 100));
3158
+ await new Promise((resolve4) => setTimeout(resolve4, 100));
3115
3159
  }
3116
3160
  }
3117
3161
  }
3118
3162
 
3119
- // src/setup.ts
3120
- import { spawnSync as spawnSync5 } from "child_process";
3121
- import { accessSync, appendFileSync as appendFileSync2, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3122
- import { dirname as dirname5, resolve as resolve2 } from "path";
3163
+ // src/media-server.ts
3164
+ import { spawn as spawn4 } from "child_process";
3165
+ import { createSocket } from "dgram";
3166
+ import { createReadStream, existsSync as existsSync6, statSync as statSync2 } from "fs";
3167
+ import { createServer as createServer2 } from "http";
3168
+ import { dirname as dirname6, extname, resolve as resolve2 } from "path";
3123
3169
  import { fileURLToPath } from "url";
3170
+
3171
+ // src/input-bridge.ts
3172
+ import { spawn as spawn3 } from "child_process";
3173
+ var namedKeys = {
3174
+ AltLeft: "Alt_L",
3175
+ AltRight: "Alt_R",
3176
+ ArrowDown: "Down",
3177
+ ArrowLeft: "Left",
3178
+ ArrowRight: "Right",
3179
+ ArrowUp: "Up",
3180
+ Backquote: "grave",
3181
+ Backslash: "backslash",
3182
+ Backspace: "BackSpace",
3183
+ BracketLeft: "bracketleft",
3184
+ BracketRight: "bracketright",
3185
+ Comma: "comma",
3186
+ ContextMenu: "Menu",
3187
+ ControlLeft: "Control_L",
3188
+ ControlRight: "Control_R",
3189
+ Delete: "Delete",
3190
+ End: "End",
3191
+ Enter: "Return",
3192
+ Equal: "equal",
3193
+ Escape: "Escape",
3194
+ Home: "Home",
3195
+ Insert: "Insert",
3196
+ MetaLeft: "Super_L",
3197
+ MetaRight: "Super_R",
3198
+ Minus: "minus",
3199
+ PageDown: "Page_Down",
3200
+ PageUp: "Page_Up",
3201
+ Period: "period",
3202
+ Quote: "apostrophe",
3203
+ Semicolon: "semicolon",
3204
+ ShiftLeft: "Shift_L",
3205
+ ShiftRight: "Shift_R",
3206
+ Slash: "slash",
3207
+ Space: "space",
3208
+ Tab: "Tab"
3209
+ };
3210
+ function xKey(code) {
3211
+ if (/^Key[A-Z]$/.test(code)) return code.slice(-1).toLowerCase();
3212
+ if (/^Digit[0-9]$/.test(code)) return code.slice(-1);
3213
+ if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) return code;
3214
+ if (/^Numpad[0-9]$/.test(code)) return `KP_${code.slice(-1)}`;
3215
+ return namedKeys[code];
3216
+ }
3217
+ var InputBridge = class {
3218
+ constructor(display2, width2, height2) {
3219
+ this.display = display2;
3220
+ this.width = width2;
3221
+ this.height = height2;
3222
+ }
3223
+ display;
3224
+ width;
3225
+ height;
3226
+ process;
3227
+ pointer;
3228
+ pointerFlush;
3229
+ pressedButtons = /* @__PURE__ */ new Set();
3230
+ pressedKeys = /* @__PURE__ */ new Set();
3231
+ close() {
3232
+ this.release();
3233
+ this.process?.kill("SIGTERM");
3234
+ this.process = void 0;
3235
+ }
3236
+ handle(message) {
3237
+ if (message.type === "pointer") {
3238
+ this.pointer = this.coordinates(message.x, message.y);
3239
+ this.pointerFlush ??= setImmediate(() => this.flushPointer());
3240
+ return;
3241
+ }
3242
+ if (message.type === "button") {
3243
+ this.pointer = this.coordinates(message.x, message.y);
3244
+ this.flushPointer();
3245
+ const button = message.button === 0 ? 1 : message.button === 1 ? 2 : message.button === 2 ? 3 : 0;
3246
+ if (!button) return;
3247
+ if (message.down) {
3248
+ if (this.pressedButtons.has(button)) return;
3249
+ this.pressedButtons.add(button);
3250
+ this.write(`mousedown ${button}`);
3251
+ } else {
3252
+ this.pressedButtons.delete(button);
3253
+ this.write(`mouseup ${button}`);
3254
+ }
3255
+ return;
3256
+ }
3257
+ if (message.type === "wheel") {
3258
+ this.pointer = this.coordinates(message.x, message.y);
3259
+ this.flushPointer();
3260
+ const vertical = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaY) / 80)));
3261
+ const horizontal = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaX) / 80)));
3262
+ if (Math.abs(message.deltaY) >= Math.abs(message.deltaX) && message.deltaY) {
3263
+ for (let index = 0; index < vertical; index += 1) this.write(`mouseclick ${message.deltaY < 0 ? 4 : 5}`);
3264
+ } else if (message.deltaX) {
3265
+ for (let index = 0; index < horizontal; index += 1) this.write(`mouseclick ${message.deltaX < 0 ? 6 : 7}`);
3266
+ }
3267
+ return;
3268
+ }
3269
+ if (message.type === "key") {
3270
+ const key = xKey(message.code);
3271
+ if (!key || message.repeat) return;
3272
+ if (message.down) {
3273
+ if (this.pressedKeys.has(key)) return;
3274
+ this.pressedKeys.add(key);
3275
+ this.write(`keydown ${key}`);
3276
+ } else {
3277
+ this.pressedKeys.delete(key);
3278
+ this.write(`keyup ${key}`);
3279
+ }
3280
+ return;
3281
+ }
3282
+ if (message.type === "release") this.release();
3283
+ }
3284
+ release() {
3285
+ if (this.pointerFlush) clearImmediate(this.pointerFlush);
3286
+ this.pointerFlush = void 0;
3287
+ this.pointer = void 0;
3288
+ for (const button of this.pressedButtons) this.write(`mouseup ${button}`);
3289
+ for (const key of this.pressedKeys) this.write(`keyup ${key}`);
3290
+ this.pressedButtons.clear();
3291
+ this.pressedKeys.clear();
3292
+ }
3293
+ coordinates(x, y) {
3294
+ return {
3295
+ x: Math.round(Math.min(1, Math.max(0, x)) * (this.width - 1)),
3296
+ y: Math.round(Math.min(1, Math.max(0, y)) * (this.height - 1))
3297
+ };
3298
+ }
3299
+ flushPointer() {
3300
+ if (this.pointerFlush) clearImmediate(this.pointerFlush);
3301
+ this.pointerFlush = void 0;
3302
+ if (!this.pointer) return;
3303
+ this.write(`mousemove ${this.pointer.x} ${this.pointer.y}`);
3304
+ this.pointer = void 0;
3305
+ }
3306
+ write(command) {
3307
+ if (!this.process || this.process.exitCode !== null || this.process.stdin.destroyed) {
3308
+ const child = spawn3("xte", [], {
3309
+ env: { ...process.env, DISPLAY: this.display },
3310
+ stdio: ["pipe", "pipe", "pipe"]
3311
+ });
3312
+ child.stdout.resume();
3313
+ child.stderr.resume();
3314
+ child.stdin.on("error", () => {
3315
+ });
3316
+ child.once("exit", () => {
3317
+ if (this.process === child) this.process = void 0;
3318
+ });
3319
+ this.process = child;
3320
+ }
3321
+ this.process.stdin.write(`${command}
3322
+ `);
3323
+ }
3324
+ };
3325
+
3326
+ // src/media-protocol.ts
3327
+ var STREAM_PROTOCOL_VERSION = 1;
3328
+ var MEDIA_PACKET_HEADER_BYTES = 12;
3329
+
3330
+ // src/rtp.ts
3331
+ function parseRtpPacket(data) {
3332
+ if (data.length < 12 || data[0] >> 6 !== 2) return null;
3333
+ if (data[1] >= 192 && data[1] <= 223) return null;
3334
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
3335
+ const padding = Boolean(data[0] & 32);
3336
+ const extension = Boolean(data[0] & 16);
3337
+ let offset = 12 + (data[0] & 15) * 4;
3338
+ if (offset > data.length) return null;
3339
+ if (extension) {
3340
+ if (offset + 4 > data.length) return null;
3341
+ offset += 4 + view.getUint16(offset + 2) * 4;
3342
+ }
3343
+ const end = data.length - (padding ? data[data.length - 1] : 0);
3344
+ if (offset >= end) return null;
3345
+ return {
3346
+ marker: Boolean(data[1] & 128),
3347
+ payload: data.subarray(offset, end),
3348
+ sequence: view.getUint16(2),
3349
+ timestamp: view.getUint32(4)
3350
+ };
3351
+ }
3352
+ var RtpClock = class {
3353
+ constructor(rate) {
3354
+ this.rate = rate;
3355
+ }
3356
+ rate;
3357
+ base;
3358
+ last;
3359
+ wraps = 0;
3360
+ reset() {
3361
+ this.base = void 0;
3362
+ this.last = void 0;
3363
+ this.wraps = 0;
3364
+ }
3365
+ microseconds(timestamp) {
3366
+ if (this.last !== void 0 && timestamp < this.last && this.last - timestamp > 2147483648) this.wraps += 1;
3367
+ this.last = timestamp;
3368
+ const extended = timestamp + this.wraps * 4294967296;
3369
+ this.base ??= extended;
3370
+ return Math.round((extended - this.base) * 1e6 / this.rate);
3371
+ }
3372
+ };
3373
+ var startCode = new Uint8Array([0, 0, 0, 1]);
3374
+ var H264RtpAssembler = class {
3375
+ clock = new RtpClock(9e4);
3376
+ chunks = [];
3377
+ key = false;
3378
+ timestamp;
3379
+ sequence;
3380
+ fragmentType;
3381
+ damaged = false;
3382
+ clearFrame() {
3383
+ this.chunks = [];
3384
+ this.key = false;
3385
+ this.timestamp = void 0;
3386
+ this.sequence = void 0;
3387
+ this.fragmentType = void 0;
3388
+ this.damaged = false;
3389
+ }
3390
+ reset() {
3391
+ this.clock.reset();
3392
+ this.clearFrame();
3393
+ }
3394
+ push(packet) {
3395
+ if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
3396
+ this.clearFrame();
3397
+ }
3398
+ this.timestamp ??= packet.timestamp;
3399
+ if (this.sequence !== void 0 && packet.sequence !== (this.sequence + 1) % 65536) this.damaged = true;
3400
+ this.sequence = packet.sequence;
3401
+ const type = packet.payload[0] & 31;
3402
+ if (this.damaged) {
3403
+ } else if (type > 0 && type < 24 && this.fragmentType === void 0) {
3404
+ this.chunks.push(startCode, packet.payload);
3405
+ this.key ||= type === 5;
3406
+ } else if (type === 24 && this.fragmentType === void 0) {
3407
+ let offset2 = 1;
3408
+ let nals = 0;
3409
+ while (offset2 < packet.payload.length) {
3410
+ if (offset2 + 2 > packet.payload.length) {
3411
+ this.damaged = true;
3412
+ break;
3413
+ }
3414
+ const size = packet.payload[offset2] * 256 + packet.payload[offset2 + 1];
3415
+ offset2 += 2;
3416
+ if (!size || offset2 + size > packet.payload.length) {
3417
+ this.damaged = true;
3418
+ break;
3419
+ }
3420
+ const nal = packet.payload.subarray(offset2, offset2 + size);
3421
+ this.chunks.push(startCode, nal);
3422
+ this.key ||= (nal[0] & 31) === 5;
3423
+ offset2 += size;
3424
+ nals += 1;
3425
+ }
3426
+ if (!nals) this.damaged = true;
3427
+ } else if (type === 28 && packet.payload.length >= 3) {
3428
+ const fragmentType = packet.payload[1] & 31;
3429
+ const start = Boolean(packet.payload[1] & 128);
3430
+ const end = Boolean(packet.payload[1] & 64);
3431
+ if (start && !end && this.fragmentType === void 0) {
3432
+ this.fragmentType = fragmentType;
3433
+ this.chunks.push(startCode, new Uint8Array([packet.payload[0] & 224 | fragmentType]));
3434
+ this.key ||= fragmentType === 5;
3435
+ } else if (!start && this.fragmentType === fragmentType) {
3436
+ if (end) this.fragmentType = void 0;
3437
+ } else {
3438
+ this.damaged = true;
3439
+ }
3440
+ if (!this.damaged) this.chunks.push(packet.payload.subarray(2));
3441
+ } else this.damaged = true;
3442
+ if (!packet.marker) return null;
3443
+ if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
3444
+ this.clearFrame();
3445
+ return null;
3446
+ }
3447
+ const length = this.chunks.reduce((total, chunk) => total + chunk.length, 0);
3448
+ const data = new Uint8Array(length);
3449
+ let offset = 0;
3450
+ for (const chunk of this.chunks) {
3451
+ data.set(chunk, offset);
3452
+ offset += chunk.length;
3453
+ }
3454
+ const frame = { data, key: this.key, timestamp: this.clock.microseconds(packet.timestamp) };
3455
+ this.clearFrame();
3456
+ return frame;
3457
+ }
3458
+ };
3459
+
3460
+ // src/media-server.ts
3461
+ import WebSocket2, { WebSocketServer } from "ws";
3462
+ var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
3463
+ var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
3464
+ var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "60", 10);
3465
+ var keyframeInterval = fps;
3466
+ var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
3467
+ var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
3468
+ var videoCodec = "avc1.42E02A";
3469
+ var config = {
3470
+ type: "config",
3471
+ protocolVersion: STREAM_PROTOCOL_VERSION,
3472
+ width,
3473
+ height,
3474
+ fps,
3475
+ videoCodec,
3476
+ audioCodec: "opus",
3477
+ audioSampleRate: 48e3,
3478
+ audioChannels: 2
3479
+ };
3480
+ var packageRoot = resolve2(dirname6(fileURLToPath(import.meta.url)), "..");
3481
+ var webroot = process.env.REPLICAS_DESKTOP_STREAM_WEBROOT || resolve2(packageRoot, "assets", "desktop");
3482
+ var contentTypes = {
3483
+ ".css": "text/css; charset=utf-8",
3484
+ ".html": "text/html; charset=utf-8",
3485
+ ".js": "text/javascript; charset=utf-8",
3486
+ ".svg": "image/svg+xml",
3487
+ ".woff2": "font/woff2"
3488
+ };
3489
+ function bindUdp() {
3490
+ return new Promise((resolveSocket, reject) => {
3491
+ const socket = createSocket("udp4");
3492
+ socket.once("error", reject);
3493
+ socket.bind(0, "127.0.0.1", () => {
3494
+ socket.off("error", reject);
3495
+ socket.setRecvBufferSize(4 * 1024 * 1024);
3496
+ resolveSocket(socket);
3497
+ });
3498
+ });
3499
+ }
3500
+ function mediaPacket(kind, timestamp, payload) {
3501
+ const header = Buffer.alloc(MEDIA_PACKET_HEADER_BYTES);
3502
+ header[0] = kind;
3503
+ header[1] = STREAM_PROTOCOL_VERSION;
3504
+ header.writeBigUInt64BE(BigInt(Math.max(0, timestamp)), 4);
3505
+ return Buffer.concat([header, payload]);
3506
+ }
3507
+ function validInput(value) {
3508
+ if (!value || typeof value !== "object" || !("type" in value) || typeof value.type !== "string") return false;
3509
+ if (value.type === "release") return true;
3510
+ if (value.type === "stats") return "videoFps" in value && typeof value.videoFps === "number" && Number.isFinite(value.videoFps) && "audioPacketsPerSecond" in value && typeof value.audioPacketsPerSecond === "number" && Number.isFinite(value.audioPacketsPerSecond) && "decodeQueue" in value && typeof value.decodeQueue === "number" && Number.isFinite(value.decodeQueue) && "rttMs" in value && typeof value.rttMs === "number" && Number.isFinite(value.rttMs);
3511
+ if (value.type === "ping") return "sentAt" in value && typeof value.sentAt === "number";
3512
+ if (value.type === "key") return "code" in value && typeof value.code === "string" && value.code.length < 40 && "down" in value && typeof value.down === "boolean";
3513
+ if (!("x" in value) || !("y" in value) || typeof value.x !== "number" || typeof value.y !== "number") return false;
3514
+ if (value.type === "pointer") return true;
3515
+ if (value.type === "button") return "button" in value && typeof value.button === "number" && "down" in value && typeof value.down === "boolean";
3516
+ return value.type === "wheel" && "deltaX" in value && typeof value.deltaX === "number" && "deltaY" in value && typeof value.deltaY === "number";
3517
+ }
3518
+ async function runMediaServer() {
3519
+ if (![width, height, fps, port].every((value) => Number.isInteger(value) && value > 0)) throw new Error("Invalid desktop stream dimensions, frame rate, or port");
3520
+ const videoSocket = await bindUdp();
3521
+ const audioSocket = await bindUdp();
3522
+ const videoPort = videoSocket.address().port;
3523
+ const audioPort = audioSocket.address().port;
3524
+ const videoAssembler = new H264RtpAssembler();
3525
+ const audioClock = new RtpClock(48e3);
3526
+ const clients = /* @__PURE__ */ new Map();
3527
+ let encoder;
3528
+ let encoderRestart;
3529
+ let encoderStop;
3530
+ let lastVideoAt = 0;
3531
+ let lastAudioAt = 0;
3532
+ let videoFrames = 0;
3533
+ let audioPackets = 0;
3534
+ let videoBytes = 0;
3535
+ let audioBytes = 0;
3536
+ const broadcast = (packet, keyframe = false) => {
3537
+ for (const [client, state] of clients) {
3538
+ if (client.readyState !== WebSocket2.OPEN) continue;
3539
+ if (client.bufferedAmount > 4 * 1024 * 1024) {
3540
+ client.close(1013, "stream client is too slow");
3541
+ continue;
3542
+ }
3543
+ if (!keyframe && (state.waitingForKeyframe || client.bufferedAmount > 512 * 1024)) {
3544
+ state.waitingForKeyframe = true;
3545
+ continue;
3546
+ }
3547
+ if (keyframe) state.waitingForKeyframe = false;
3548
+ client.send(packet, { binary: true });
3549
+ }
3550
+ };
3551
+ videoSocket.on("message", (data) => {
3552
+ const rtp = parseRtpPacket(data);
3553
+ if (!rtp) return;
3554
+ const frame = videoAssembler.push(rtp);
3555
+ if (!frame) return;
3556
+ lastVideoAt = Date.now();
3557
+ videoFrames += 1;
3558
+ videoBytes += frame.data.byteLength;
3559
+ const packet = mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data);
3560
+ broadcast(packet, frame.key);
3561
+ });
3562
+ audioSocket.on("message", (data) => {
3563
+ const rtp = parseRtpPacket(data);
3564
+ if (!rtp) return;
3565
+ lastAudioAt = Date.now();
3566
+ audioPackets += 1;
3567
+ audioBytes += rtp.payload.byteLength;
3568
+ broadcast(mediaPacket(3 /* Audio */, audioClock.microseconds(rtp.timestamp), rtp.payload));
3569
+ });
3570
+ const startEncoder = () => {
3571
+ clearTimeout(encoderStop);
3572
+ if (encoder && encoder.exitCode === null) return;
3573
+ clearTimeout(encoderRestart);
3574
+ videoAssembler.reset();
3575
+ audioClock.reset();
3576
+ const videoBitrate = process.env.REPLICAS_DESKTOP_STREAM_VIDEO_BITRATE || "12M";
3577
+ encoder = spawn4("ffmpeg", [
3578
+ "-hide_banner",
3579
+ "-loglevel",
3580
+ "warning",
3581
+ "-fflags",
3582
+ "nobuffer",
3583
+ "-flags",
3584
+ "low_delay",
3585
+ "-thread_queue_size",
3586
+ "1024",
3587
+ "-f",
3588
+ "x11grab",
3589
+ "-draw_mouse",
3590
+ "0",
3591
+ "-framerate",
3592
+ String(fps),
3593
+ "-video_size",
3594
+ `${width}x${height}`,
3595
+ "-use_wallclock_as_timestamps",
3596
+ "1",
3597
+ "-i",
3598
+ `${display}.0`,
3599
+ "-thread_queue_size",
3600
+ "1024",
3601
+ "-f",
3602
+ "pulse",
3603
+ "-fragment_size",
3604
+ "2048",
3605
+ "-use_wallclock_as_timestamps",
3606
+ "1",
3607
+ "-i",
3608
+ process.env.REPLICAS_DESKTOP_STREAM_AUDIO_SOURCE || "default",
3609
+ "-map",
3610
+ "0:v:0",
3611
+ "-an",
3612
+ "-c:v",
3613
+ "libx264",
3614
+ "-preset",
3615
+ "ultrafast",
3616
+ "-tune",
3617
+ "zerolatency",
3618
+ "-profile:v",
3619
+ "baseline",
3620
+ "-level:v",
3621
+ "4.2",
3622
+ "-pix_fmt",
3623
+ "yuv420p",
3624
+ "-bf",
3625
+ "0",
3626
+ "-g",
3627
+ String(keyframeInterval),
3628
+ "-keyint_min",
3629
+ String(keyframeInterval),
3630
+ "-sc_threshold",
3631
+ "0",
3632
+ "-crf",
3633
+ "18",
3634
+ "-maxrate",
3635
+ videoBitrate,
3636
+ "-bufsize",
3637
+ "1M",
3638
+ "-x264-params",
3639
+ `repeat-headers=1:sliced-threads=1:slices=4:keyint=${keyframeInterval}:min-keyint=${keyframeInterval}:scenecut=0`,
3640
+ "-fps_mode",
3641
+ "passthrough",
3642
+ "-flush_packets",
3643
+ "1",
3644
+ "-f",
3645
+ "rtp",
3646
+ "-payload_type",
3647
+ "96",
3648
+ `rtp://127.0.0.1:${videoPort}?pkt_size=1200&connect=1&rtcpport=${videoPort}`,
3649
+ "-map",
3650
+ "1:a:0",
3651
+ "-vn",
3652
+ "-af",
3653
+ "aresample=async=1:first_pts=0",
3654
+ "-c:a",
3655
+ "libopus",
3656
+ "-application",
3657
+ "lowdelay",
3658
+ "-frame_duration",
3659
+ "20",
3660
+ "-b:a",
3661
+ "128k",
3662
+ "-ar",
3663
+ "48000",
3664
+ "-ac",
3665
+ "2",
3666
+ "-flush_packets",
3667
+ "1",
3668
+ "-f",
3669
+ "rtp",
3670
+ "-payload_type",
3671
+ "111",
3672
+ `rtp://127.0.0.1:${audioPort}?pkt_size=1200&connect=1&rtcpport=${audioPort}`
3673
+ ], { env: { ...process.env, DISPLAY: display }, stdio: ["ignore", "ignore", "pipe"] });
3674
+ encoder.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
3675
+ encoder.once("exit", (code, signal) => {
3676
+ encoder = void 0;
3677
+ if (clients.size > 0) {
3678
+ process.stderr.write(`[desktop-stream] encoder exited (${signal || code}); restarting
3679
+ `);
3680
+ encoderRestart = setTimeout(startEncoder, 500);
3681
+ }
3682
+ });
3683
+ };
3684
+ const stopEncoderSoon = () => {
3685
+ clearTimeout(encoderStop);
3686
+ encoderStop = setTimeout(() => {
3687
+ if (clients.size > 0) return;
3688
+ encoder?.kill("SIGTERM");
3689
+ encoder = void 0;
3690
+ }, 5e3);
3691
+ };
3692
+ const httpServer = createServer2((request, response) => {
3693
+ const url = new URL(request.url || "/", "http://localhost");
3694
+ if (url.pathname === "/health") {
3695
+ response.setHeader("content-type", "application/json");
3696
+ response.end(JSON.stringify({
3697
+ clients: clients.size,
3698
+ encoder: Boolean(encoder && encoder.exitCode === null),
3699
+ lastVideoFrameMsAgo: lastVideoAt ? Date.now() - lastVideoAt : null,
3700
+ lastAudioPacketMsAgo: lastAudioAt ? Date.now() - lastAudioAt : null,
3701
+ videoFrames,
3702
+ audioPackets,
3703
+ videoBytes,
3704
+ audioBytes,
3705
+ clientStats: [...clients.values()].flatMap((client) => client.stats ? [client.stats] : []),
3706
+ width,
3707
+ height,
3708
+ fps
3709
+ }));
3710
+ return;
3711
+ }
3712
+ const requested = url.pathname === "/" ? "stream-index.html" : url.pathname.slice(1);
3713
+ const path = resolve2(webroot, requested);
3714
+ if (!path.startsWith(resolve2(webroot) + "/") || !existsSync6(path) || !statSync2(path).isFile()) {
3715
+ response.statusCode = 404;
3716
+ response.end("Not found");
3717
+ return;
3718
+ }
3719
+ response.setHeader("cache-control", "no-store");
3720
+ response.setHeader("content-type", contentTypes[extname(path)] || "application/octet-stream");
3721
+ createReadStream(path).pipe(response);
3722
+ });
3723
+ const webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
3724
+ httpServer.on("upgrade", (request, socket, head) => {
3725
+ if (new URL(request.url || "/", "http://localhost").pathname !== "/stream") {
3726
+ socket.destroy();
3727
+ return;
3728
+ }
3729
+ webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, request));
3730
+ });
3731
+ webSocketServer.on("connection", (client) => {
3732
+ clients.set(client, { waitingForKeyframe: true, input: new InputBridge(display, width, height) });
3733
+ client.send(JSON.stringify(config));
3734
+ startEncoder();
3735
+ client.on("message", (raw, binary) => {
3736
+ if (binary) return;
3737
+ let message;
3738
+ try {
3739
+ message = JSON.parse(raw.toString());
3740
+ } catch {
3741
+ return;
3742
+ }
3743
+ if (!validInput(message)) return;
3744
+ if (message.type === "ping") client.send(JSON.stringify({ type: "pong", sentAt: message.sentAt }));
3745
+ else if (message.type === "stats") {
3746
+ const state = clients.get(client);
3747
+ if (!state) return;
3748
+ state.stats = {
3749
+ videoFps: message.videoFps,
3750
+ audioPacketsPerSecond: message.audioPacketsPerSecond,
3751
+ decodeQueue: message.decodeQueue,
3752
+ rttMs: message.rttMs
3753
+ };
3754
+ } else clients.get(client)?.input.handle(message);
3755
+ });
3756
+ client.once("close", () => {
3757
+ clients.get(client)?.input.close();
3758
+ clients.delete(client);
3759
+ if (clients.size === 0) stopEncoderSoon();
3760
+ });
3761
+ });
3762
+ await new Promise((resolveServer, reject) => {
3763
+ httpServer.once("error", reject);
3764
+ httpServer.listen(port, "0.0.0.0", () => {
3765
+ httpServer.off("error", reject);
3766
+ console.log(`replicas desktop stream listening on ${port}`);
3767
+ resolveServer();
3768
+ });
3769
+ });
3770
+ await new Promise((resolveShutdown) => {
3771
+ let closing = false;
3772
+ const close = () => {
3773
+ if (closing) return;
3774
+ closing = true;
3775
+ clearTimeout(encoderRestart);
3776
+ clearTimeout(encoderStop);
3777
+ for (const [client, state] of clients) {
3778
+ state.input.close();
3779
+ client.close(1001, "server shutting down");
3780
+ }
3781
+ encoder?.kill("SIGTERM");
3782
+ videoSocket.close();
3783
+ audioSocket.close();
3784
+ webSocketServer.close();
3785
+ httpServer.close(() => resolveShutdown());
3786
+ };
3787
+ process.once("SIGTERM", close);
3788
+ process.once("SIGINT", close);
3789
+ });
3790
+ }
3791
+
3792
+ // src/setup.ts
3793
+ import { spawnSync as spawnSync6 } from "child_process";
3794
+ import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
3795
+ import { dirname as dirname7, resolve as resolve3 } from "path";
3796
+ import { fileURLToPath as fileURLToPath2 } from "url";
3124
3797
  var dependencies = [
3125
3798
  ["bash", "bash"],
3126
3799
  ["Node.js", "node"],
@@ -3133,6 +3806,9 @@ var dependencies = [
3133
3806
  ["tint2", "tint2"],
3134
3807
  ["picom", "picom"],
3135
3808
  ["ffmpeg", "ffmpeg"],
3809
+ ["PulseAudio", "pulseaudio"],
3810
+ ["PulseAudio control", "pactl"],
3811
+ ["X automation", "xte"],
3136
3812
  ["x11vnc", "x11vnc"],
3137
3813
  ["websockify", "websockify"],
3138
3814
  ["Google Chrome", "google-chrome-stable"],
@@ -3169,13 +3845,13 @@ StandardError=inherit
3169
3845
  WantedBy=multi-user.target
3170
3846
  `;
3171
3847
  function commandVersion(command) {
3172
- const result = spawnSync5(command, ["--version"], { encoding: "utf8" });
3848
+ const result = spawnSync6(command, ["--version"], { encoding: "utf8" });
3173
3849
  return `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 200) || "available";
3174
3850
  }
3175
3851
  function hasCommand(command) {
3176
3852
  return (process.env.PATH ?? "").split(":").some((directory) => {
3177
3853
  try {
3178
- accessSync(resolve2(directory, command), constants.X_OK);
3854
+ accessSync(resolve3(directory, command), constants.X_OK);
3179
3855
  return true;
3180
3856
  } catch {
3181
3857
  return false;
@@ -3195,7 +3871,7 @@ function setupComputer(options = {}) {
3195
3871
  }
3196
3872
  }
3197
3873
  if (found.ffmpeg) {
3198
- const encoders = spawnSync5("ffmpeg", ["-hide_banner", "-encoders"], { encoding: "utf8" });
3874
+ const encoders = spawnSync6("ffmpeg", ["-hide_banner", "-encoders"], { encoding: "utf8" });
3199
3875
  if (!`${encoders.stdout}${encoders.stderr}`.includes("libx264")) {
3200
3876
  missing.push("ffmpeg encoder libx264");
3201
3877
  console.error("\u2717 ffmpeg encoder libx264 is unavailable");
@@ -3203,47 +3879,55 @@ function setupComputer(options = {}) {
3203
3879
  console.log("\u2713 ffmpeg encoder libx264");
3204
3880
  }
3205
3881
  }
3206
- const noVncRoot = ["/usr/share/novnc", "/usr/share/novnc-common"].find(existsSync5);
3207
- if (!noVncRoot) {
3208
- missing.push("noVNC web root");
3209
- console.error("\u2717 noVNC web root \u2014 /usr/share/novnc and /usr/share/novnc-common are missing");
3882
+ const noVncPackageRoot = dirname7(dirname7(fileURLToPath2(import.meta.resolve("@novnc/novnc"))));
3883
+ const noVncCore = resolve3(noVncPackageRoot, "core");
3884
+ const noVncVendor = resolve3(noVncPackageRoot, "vendor");
3885
+ if (!existsSync7(noVncCore) || !existsSync7(noVncVendor)) {
3886
+ missing.push("bundled noVNC client");
3887
+ console.error("\u2717 bundled noVNC client");
3210
3888
  } else {
3211
- console.log(`\u2713 noVNC web root (${noVncRoot})`);
3889
+ console.log("\u2713 bundled noVNC client");
3212
3890
  }
3213
- if (spawnSync5("id", ["user"], { stdio: "ignore" }).status !== 0) {
3891
+ if (spawnSync6("id", ["user"], { stdio: "ignore" }).status !== 0) {
3214
3892
  missing.push("workspace user account");
3215
3893
  console.error("\u2717 workspace user account \u2014 user does not exist");
3216
3894
  } else {
3217
3895
  console.log("\u2713 workspace user account");
3218
3896
  }
3219
3897
  if (missing.length > 0) throw new Error(`Missing ${missing.length} computer-use requirement${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
3220
- if (!noVncRoot) throw new Error("noVNC web root is unavailable");
3221
3898
  if (options.checkOnly) return;
3222
3899
  if (process.getuid?.() !== 0) throw new Error("replicas-computer setup must run as root to install runtime files");
3223
- const packageRoot = resolve2(dirname5(fileURLToPath(import.meta.url)), "..");
3224
- const assets = resolve2(packageRoot, "assets", "desktop");
3225
- if (!existsSync5(assets)) throw new Error(`Computer-use assets are missing from ${assets}`);
3226
- mkdirSync5("/usr/local/share/replicas/desktop", { recursive: true });
3900
+ const packageRoot2 = resolve3(dirname7(fileURLToPath2(import.meta.url)), "..");
3901
+ const assets = resolve3(packageRoot2, "assets", "desktop");
3902
+ if (!existsSync7(assets)) throw new Error(`Computer-use assets are missing from ${assets}`);
3903
+ mkdirSync6("/usr/local/share/replicas/desktop", { recursive: true });
3227
3904
  cpSync(assets, "/usr/local/share/replicas/desktop", { recursive: true, force: true });
3228
- cpSync(resolve2(assets, "start-services.sh"), "/usr/local/bin/replicas-start-desktop-services", { force: true });
3229
- cpSync(resolve2(assets, "replicas-chrome"), "/usr/local/bin/replicas-chrome", { force: true });
3230
- cpSync(resolve2(assets, "replicas-chrome.desktop"), "/usr/share/applications/replicas-chrome.desktop", { force: true });
3905
+ const viewerRoot = "/usr/local/share/replicas/desktop/novnc";
3906
+ mkdirSync6(viewerRoot, { recursive: true });
3907
+ cpSync(noVncCore, resolve3(viewerRoot, "core"), { recursive: true, force: true });
3908
+ cpSync(noVncVendor, resolve3(viewerRoot, "vendor"), { recursive: true, force: true });
3909
+ for (const file of ["novnc-index.html", "viewer.css", "viewer-icon.svg", "geist-mono.woff2"]) {
3910
+ cpSync(resolve3(assets, file), resolve3(viewerRoot, file === "novnc-index.html" ? "index.html" : file), { force: true });
3911
+ }
3912
+ cpSync(resolve3(packageRoot2, "dist", "viewer.js"), resolve3(viewerRoot, "viewer.js"), { force: true });
3913
+ const streamRoot = "/usr/local/share/replicas/desktop/stream";
3914
+ mkdirSync6(streamRoot, { recursive: true });
3915
+ for (const file of ["stream-index.html", "stream-viewer.css", "viewer-icon.svg", "geist-mono.woff2"]) {
3916
+ cpSync(resolve3(assets, file), resolve3(streamRoot, file), { force: true });
3917
+ }
3918
+ cpSync(resolve3(packageRoot2, "dist", "stream-viewer.js"), resolve3(streamRoot, "stream-viewer.js"), { force: true });
3919
+ cpSync(resolve3(assets, "start-services.sh"), "/usr/local/bin/replicas-start-desktop-services", { force: true });
3920
+ cpSync(resolve3(assets, "replicas-chrome"), "/usr/local/bin/replicas-chrome", { force: true });
3921
+ cpSync(resolve3(assets, "replicas-chrome.desktop"), "/usr/share/applications/replicas-chrome.desktop", { force: true });
3231
3922
  chmodSync2("/usr/local/bin/replicas-start-desktop-services", 493);
3232
3923
  chmodSync2("/usr/local/bin/replicas-chrome", 493);
3233
- cpSync(resolve2(assets, "novnc-index.html"), resolve2(noVncRoot, "index.html"), { force: true });
3234
- cpSync(resolve2(assets, "geist-mono.woff2"), resolve2(noVncRoot, "geist-mono.woff2"), { force: true });
3235
- const noVncStyles = resolve2(noVncRoot, "app", "styles", "base.css");
3236
- const styleOverride = "\n#noVNC_status { display: none !important; }\n#noVNC_control_bar_anchor { display: none !important; }\n";
3237
- if (existsSync5(noVncStyles) && !readFileSync4(noVncStyles, "utf8").includes("#noVNC_status { display: none !important; }")) {
3238
- appendFileSync2(noVncStyles, styleOverride);
3239
- }
3240
- writeFileSync4("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
3241
- if (existsSync5("/run/systemd/system")) {
3242
- const reload = spawnSync5("systemctl", ["daemon-reload"], { stdio: "pipe" });
3924
+ writeFileSync5("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
3925
+ if (existsSync7("/run/systemd/system")) {
3926
+ const reload = spawnSync6("systemctl", ["daemon-reload"], { stdio: "pipe" });
3243
3927
  if (reload.status !== 0) throw new Error(`Failed to reload systemd: ${reload.stderr?.toString().trim() || `exit ${reload.status}`}`);
3244
3928
  }
3245
- mkdirSync5("/usr/local/share/replicas-computer", { recursive: true });
3246
- writeFileSync4("/usr/local/share/replicas-computer/install.json", `${JSON.stringify({
3929
+ mkdirSync6("/usr/local/share/replicas-computer", { recursive: true });
3930
+ writeFileSync5("/usr/local/share/replicas-computer/install.json", `${JSON.stringify({
3247
3931
  version: COMPUTER_USE_VERSION,
3248
3932
  protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
3249
3933
  dependencies: found
@@ -3273,6 +3957,11 @@ async function main() {
3273
3957
  await runComputerDaemon();
3274
3958
  return;
3275
3959
  }
3960
+ if (argv[0] === "stream") {
3961
+ if (argv.length > 1) throw new Error(`Unknown stream option: ${argv[1]}`);
3962
+ await runMediaServer();
3963
+ return;
3964
+ }
3276
3965
  const direct = argv[0] === "--direct";
3277
3966
  const commandArgv = direct ? argv.slice(1) : argv;
3278
3967
  const local = commandArgv.length === 0 || ["--help", "-h", "--version", "-V", "help"].includes(commandArgv[0]);