@instaclaw/dispatch 0.4.0 → 0.5.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.
@@ -37,13 +37,17 @@ export function connect(opts) {
37
37
  const result = await executeCommand(command);
38
38
  commandsExecuted++;
39
39
  if (result.screenshotBuffer && result.screenshotMeta) {
40
- // Screenshot: send metadata text frame, then binary frame
40
+ // Screenshot (or batch with screenshot): send metadata text frame, then binary frame
41
41
  const meta = {
42
42
  id: command.id,
43
43
  type: "screenshot_result",
44
44
  ...result.screenshotMeta,
45
45
  size: result.screenshotBuffer.length,
46
46
  };
47
+ // For batch commands, include action results in the metadata frame
48
+ if (result.data) {
49
+ meta.data = result.data;
50
+ }
47
51
  ws?.send(JSON.stringify(meta));
48
52
  ws?.send(result.screenshotBuffer);
49
53
  }
package/dist/executor.js CHANGED
@@ -32,6 +32,8 @@ export async function executeCommand(command) {
32
32
  return await doDrag(params);
33
33
  case "windows":
34
34
  return await listWindows();
35
+ case "batch":
36
+ return await executeBatch(params);
35
37
  default:
36
38
  return { success: false, error: `Unknown command type: ${type}` };
37
39
  }
@@ -40,10 +42,87 @@ export async function executeCommand(command) {
40
42
  return { success: false, error: String(err) };
41
43
  }
42
44
  }
45
+ /**
46
+ * Execute multiple actions sequentially with optional per-action wait times.
47
+ * Returns results for all actions; stops on first failure.
48
+ * Optionally takes a screenshot after the batch completes.
49
+ */
50
+ async function executeBatch(params) {
51
+ const actions = params.actions;
52
+ if (!Array.isArray(actions) || actions.length === 0) {
53
+ return { success: false, error: "batch requires a non-empty 'actions' array" };
54
+ }
55
+ if (actions.length > 20) {
56
+ return { success: false, error: "batch limited to 20 actions max" };
57
+ }
58
+ const results = [];
59
+ for (const action of actions) {
60
+ const cmd = {
61
+ id: `batch_${Date.now()}`,
62
+ type: action.type,
63
+ params: action.params || {},
64
+ };
65
+ // Don't allow nested batches or screenshots inside a batch
66
+ if (cmd.type === "batch") {
67
+ results.push({ type: "batch", success: false, error: "nested batches not allowed" });
68
+ break;
69
+ }
70
+ if (cmd.type === "screenshot") {
71
+ results.push({ type: "screenshot", success: false, error: "use screenshotAfter param instead of screenshot in batch" });
72
+ break;
73
+ }
74
+ const result = await executeCommand(cmd);
75
+ results.push({
76
+ type: action.type,
77
+ success: result.success,
78
+ data: result.data,
79
+ error: result.error,
80
+ });
81
+ if (!result.success)
82
+ break; // Stop batch on first failure
83
+ // Per-action wait time (default: 50ms between actions)
84
+ const waitMs = action.waitAfterMs ?? 50;
85
+ if (waitMs > 0) {
86
+ await new Promise((r) => setTimeout(r, waitMs));
87
+ }
88
+ }
89
+ // Optionally take a screenshot after the batch completes
90
+ const screenshotAfter = params.screenshotAfter !== false; // default true
91
+ let screenshotResult;
92
+ if (screenshotAfter) {
93
+ // Wait for screen to settle before capturing
94
+ const settleMs = typeof params.settleMs === "number" ? params.settleMs : 300;
95
+ if (settleMs > 0) {
96
+ await new Promise((r) => setTimeout(r, settleMs));
97
+ }
98
+ screenshotResult = await takeScreenshot({
99
+ format: params.screenshotFormat || "webp",
100
+ quality: Number(params.screenshotQuality) || 55,
101
+ });
102
+ }
103
+ const batchResult = {
104
+ success: results.every((r) => r.success),
105
+ data: {
106
+ action: "batch",
107
+ actionsExecuted: results.length,
108
+ actionsTotal: actions.length,
109
+ results,
110
+ },
111
+ };
112
+ // If we took a screenshot, attach it
113
+ if (screenshotResult?.screenshotBuffer && screenshotResult.screenshotMeta) {
114
+ batchResult.screenshotBuffer = screenshotResult.screenshotBuffer;
115
+ batchResult.screenshotMeta = screenshotResult.screenshotMeta;
116
+ // Also include batch results in the screenshot meta so the agent gets both
117
+ batchResult.data.screenshotIncluded = true;
118
+ }
119
+ return batchResult;
120
+ }
43
121
  async function takeScreenshot(params) {
44
122
  const capture = await captureScreenshot({
45
- format: params.format || "jpeg",
46
- quality: Number(params.quality) || 80,
123
+ format: params.format || "webp",
124
+ quality: Number(params.quality) || 55,
125
+ maxWidth: Number(params.maxWidth) || 1280,
47
126
  });
48
127
  // Cache coordMap for subsequent click/hover/drag commands
49
128
  lastCoordMap = capture.coordMap;
@@ -1,6 +1,8 @@
1
1
  export interface ScreenshotOptions {
2
- format?: "jpeg" | "png";
2
+ format?: "jpeg" | "png" | "webp";
3
3
  quality?: number;
4
+ /** Max width — downscale if screen is larger (default 1280) */
5
+ maxWidth?: number;
4
6
  }
5
7
  export interface ScreenshotCapture {
6
8
  buffer: Buffer;
@@ -1,38 +1,70 @@
1
1
  /**
2
- * Screenshot — captures screen via usecomputer, converts PNG→JPEG via sharp.
3
- * Separated per PRD Section 5.1 package structure.
2
+ * Screenshot — captures screen via usecomputer, compresses via sharp.
3
+ * Optimized for speed: WebP format, quality 55, resolution capped at 1280x720.
4
4
  */
5
5
  import fs from "fs";
6
6
  import path from "path";
7
+ // Pre-warm sharp at module load (avoid dynamic import per screenshot)
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ let sharpFn = null;
10
+ const sharpReady = import("sharp").then((m) => { sharpFn = m.default || m; }).catch(() => { });
11
+ // Pre-warm usecomputer at module load
12
+ let ucMod = null;
13
+ const ucReady = import("usecomputer").then((m) => { ucMod = m; }).catch(() => { });
7
14
  /**
8
15
  * Capture a screenshot and return it as a buffer with metadata.
9
16
  */
10
17
  export async function captureScreenshot(opts = {}) {
11
- const uc = await import("usecomputer");
18
+ // Ensure modules are loaded
19
+ if (!ucMod)
20
+ await ucReady;
21
+ if (!sharpFn)
22
+ await sharpReady;
23
+ const uc = ucMod;
12
24
  const tmpPath = path.join("/tmp", `dispatch-ss-${Date.now()}.png`);
13
- const format = opts.format || "jpeg";
14
- const quality = opts.quality || 80;
25
+ const format = opts.format || "webp";
26
+ const quality = opts.quality || 55;
27
+ const maxWidth = opts.maxWidth || 1280;
15
28
  try {
16
29
  const result = await uc.screenshot({ path: tmpPath, display: null, window: null, region: null, annotate: null });
17
30
  const pngBuffer = fs.readFileSync(tmpPath);
18
31
  let outputBuffer;
19
- if (format === "jpeg") {
32
+ let outWidth = result.imageWidth;
33
+ let outHeight = result.imageHeight;
34
+ if (sharpFn) {
20
35
  try {
21
- const sharp = (await import("sharp")).default;
22
- outputBuffer = await sharp(pngBuffer).jpeg({ quality }).toBuffer();
36
+ let pipeline = sharpFn(pngBuffer);
37
+ // Downscale if wider than maxWidth (HiDPI screens capture at 2x+)
38
+ if (result.imageWidth > maxWidth) {
39
+ pipeline = pipeline.resize(maxWidth, null, { fit: "inside", withoutEnlargement: true });
40
+ const ratio = maxWidth / result.imageWidth;
41
+ outWidth = maxWidth;
42
+ outHeight = Math.round(result.imageHeight * ratio);
43
+ }
44
+ // Encode to target format
45
+ if (format === "webp") {
46
+ outputBuffer = await pipeline.webp({ quality, effort: 2 }).toBuffer();
47
+ }
48
+ else if (format === "jpeg") {
49
+ outputBuffer = await pipeline.jpeg({ quality }).toBuffer();
50
+ }
51
+ else {
52
+ outputBuffer = await pipeline.png().toBuffer();
53
+ }
23
54
  }
24
55
  catch {
25
- // sharp not available — fall back to PNG
56
+ // sharp failed — fall back to raw PNG
26
57
  outputBuffer = pngBuffer;
27
58
  }
28
59
  }
29
60
  else {
61
+ // No sharp available — return raw PNG
30
62
  outputBuffer = pngBuffer;
31
63
  }
32
64
  return {
33
65
  buffer: outputBuffer,
34
- width: result.imageWidth,
35
- height: result.imageHeight,
66
+ width: outWidth,
67
+ height: outHeight,
36
68
  format,
37
69
  coordMap: result.coordMap,
38
70
  };
@@ -85,6 +85,13 @@ function isDangerous(cmd) {
85
85
  const key = String(p.key || "").toLowerCase();
86
86
  return DANGEROUS_KEY_COMBOS.has(key);
87
87
  }
88
+ // Check all sub-actions in a batch
89
+ if (cmd.type === "batch") {
90
+ const actions = p.actions;
91
+ if (Array.isArray(actions)) {
92
+ return actions.some((a) => isDangerous({ id: "", type: a.type, params: a.params || {} }));
93
+ }
94
+ }
88
95
  return false;
89
96
  }
90
97
  function formatCommand(cmd) {
@@ -100,6 +107,13 @@ function formatCommand(cmd) {
100
107
  return `Scroll ${p.direction} by ${p.amount || 3}`;
101
108
  case "drag":
102
109
  return `Drag from (${p.fromX}, ${p.fromY}) to (${p.toX}, ${p.toY})`;
110
+ case "batch": {
111
+ const actions = p.actions;
112
+ if (!Array.isArray(actions))
113
+ return "Batch (empty)";
114
+ const summary = actions.map((a) => formatCommand({ id: "", type: a.type, params: a.params || {} })).join(" → ");
115
+ return `Batch (${actions.length} actions): ${summary}`;
116
+ }
103
117
  default:
104
118
  return `${cmd.type}: ${JSON.stringify(p).substring(0, 80)}`;
105
119
  }
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Command sent from VM agent → dispatch server → local relay */
2
2
  export interface DispatchCommand {
3
3
  id: string;
4
- type: "screenshot" | "click" | "type" | "press" | "scroll" | "drag" | "windows" | "status";
4
+ type: "screenshot" | "click" | "type" | "press" | "scroll" | "drag" | "windows" | "status" | "batch";
5
5
  params: Record<string, unknown>;
6
6
  description?: string;
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instaclaw/dispatch",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Let your InstaClaw AI agent control your computer — screenshots, clicks, typing, and more via a secure WebSocket relay",
5
5
  "bin": {
6
6
  "instaclaw-dispatch": "./dist/index.js"