@instaclaw/dispatch 0.1.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.
@@ -0,0 +1,4 @@
1
+ import type { DispatchConfig } from "./types.js";
2
+ export declare function loadConfig(): DispatchConfig | null;
3
+ export declare function saveConfig(config: DispatchConfig): void;
4
+ export declare function getConfigPath(): string;
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ const CONFIG_DIR = path.join(process.env.HOME || "~", ".instaclaw-dispatch");
4
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
5
+ export function loadConfig() {
6
+ try {
7
+ if (fs.existsSync(CONFIG_FILE)) {
8
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
9
+ }
10
+ }
11
+ catch { }
12
+ return null;
13
+ }
14
+ export function saveConfig(config) {
15
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
16
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
17
+ }
18
+ export function getConfigPath() {
19
+ return CONFIG_FILE;
20
+ }
21
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,19 @@
1
+ interface ConnectionOptions {
2
+ vmAddress: string;
3
+ port: number;
4
+ gatewayToken: string;
5
+ mode: "supervised" | "autonomous";
6
+ /** Accept self-signed certs (TOFU) */
7
+ rejectUnauthorized: boolean;
8
+ certFingerprint?: string;
9
+ onConnect?: () => void;
10
+ onDisconnect?: () => void;
11
+ onCertFingerprint?: (fingerprint: string) => void;
12
+ }
13
+ export declare function connect(opts: ConnectionOptions): void;
14
+ export declare function disconnect(): void;
15
+ export declare function getStats(): {
16
+ connected: boolean;
17
+ commandsExecuted: number;
18
+ };
19
+ export {};
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Connection — WebSocket client that connects to the VM's dispatch server.
3
+ * Handles reconnection, heartbeat, and message routing.
4
+ */
5
+ import WebSocket from "ws";
6
+ import { executeCommand } from "./executor.js";
7
+ import { requestApproval } from "./supervisor.js";
8
+ let ws = null;
9
+ let reconnectDelay = 1000;
10
+ let shouldReconnect = true;
11
+ let commandsExecuted = 0;
12
+ export function connect(opts) {
13
+ const proto = "wss";
14
+ const url = `${proto}://${opts.vmAddress}:${opts.port}?token=${opts.gatewayToken}`;
15
+ console.log(` Connecting to ${opts.vmAddress}:${opts.port}...`);
16
+ ws = new WebSocket(url, {
17
+ rejectUnauthorized: false, // Accept self-signed certs (TOFU model)
18
+ });
19
+ ws.on("open", () => {
20
+ reconnectDelay = 1000; // Reset backoff
21
+ console.log(` Connected to agent on ${opts.vmAddress}`);
22
+ opts.onConnect?.();
23
+ });
24
+ ws.on("message", async (data, isBinary) => {
25
+ if (isBinary)
26
+ return; // We don't expect binary from the server
27
+ try {
28
+ const command = JSON.parse(data.toString());
29
+ // Supervised mode: ask user for approval
30
+ if (opts.mode === "supervised") {
31
+ const approved = await requestApproval(command);
32
+ if (!approved) {
33
+ sendResult(command.id, { success: false, error: "User denied the action" });
34
+ return;
35
+ }
36
+ }
37
+ // Execute the command
38
+ const result = await executeCommand(command);
39
+ commandsExecuted++;
40
+ if (result.screenshotBuffer && result.screenshotMeta) {
41
+ // Screenshot: send metadata text frame, then binary frame
42
+ const meta = {
43
+ id: command.id,
44
+ type: "screenshot_result",
45
+ ...result.screenshotMeta,
46
+ size: result.screenshotBuffer.length,
47
+ };
48
+ ws?.send(JSON.stringify(meta));
49
+ ws?.send(result.screenshotBuffer);
50
+ }
51
+ else {
52
+ // Normal result
53
+ sendResult(command.id, {
54
+ success: result.success,
55
+ data: result.data,
56
+ error: result.error,
57
+ });
58
+ }
59
+ }
60
+ catch (err) {
61
+ console.error(" Error processing command:", err);
62
+ }
63
+ });
64
+ ws.on("close", (code, reason) => {
65
+ console.log(` Disconnected (code=${code}${reason.length ? `, reason=${reason}` : ""})`);
66
+ ws = null;
67
+ opts.onDisconnect?.();
68
+ if (shouldReconnect) {
69
+ console.log(` Reconnecting in ${reconnectDelay / 1000}s...`);
70
+ setTimeout(() => connect(opts), reconnectDelay);
71
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
72
+ }
73
+ });
74
+ ws.on("error", (err) => {
75
+ // Suppress ECONNREFUSED noise — close handler will reconnect
76
+ if (err.code !== "ECONNREFUSED") {
77
+ console.error(" WebSocket error:", err.message);
78
+ }
79
+ });
80
+ ws.on("ping", () => { ws?.pong(); });
81
+ }
82
+ function sendResult(id, result) {
83
+ if (ws?.readyState === WebSocket.OPEN) {
84
+ ws.send(JSON.stringify({ id, type: "result", ...result }));
85
+ }
86
+ }
87
+ export function disconnect() {
88
+ shouldReconnect = false;
89
+ ws?.close();
90
+ }
91
+ export function getStats() {
92
+ return {
93
+ connected: ws?.readyState === WebSocket.OPEN,
94
+ commandsExecuted,
95
+ };
96
+ }
97
+ //# sourceMappingURL=connection.js.map
@@ -0,0 +1,15 @@
1
+ import type { DispatchCommand } from "./types.js";
2
+ export interface ExecutionResult {
3
+ success: boolean;
4
+ data?: Record<string, unknown>;
5
+ /** For screenshots: raw JPEG buffer */
6
+ screenshotBuffer?: Buffer;
7
+ screenshotMeta?: {
8
+ width: number;
9
+ height: number;
10
+ format: string;
11
+ coordMap: string;
12
+ };
13
+ error?: string;
14
+ }
15
+ export declare function executeCommand(command: DispatchCommand): Promise<ExecutionResult>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Executor — receives dispatch commands and executes them via usecomputer.
3
+ * Returns results (action confirmations or screenshot data).
4
+ */
5
+ import { execSync } from "child_process";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ // Dynamic import for usecomputer (ESM)
9
+ let uc = null;
10
+ async function getUC() {
11
+ if (!uc) {
12
+ uc = await import("usecomputer");
13
+ }
14
+ return uc;
15
+ }
16
+ export async function executeCommand(command) {
17
+ const { type, params } = command;
18
+ try {
19
+ switch (type) {
20
+ case "screenshot":
21
+ return await takeScreenshot(params);
22
+ case "click":
23
+ return await doClick(params);
24
+ case "type":
25
+ return await doType(params);
26
+ case "press":
27
+ return await doPress(params);
28
+ case "scroll":
29
+ return await doScroll(params);
30
+ case "windows":
31
+ return await listWindows();
32
+ default:
33
+ return { success: false, error: `Unknown command type: ${type}` };
34
+ }
35
+ }
36
+ catch (err) {
37
+ return { success: false, error: String(err) };
38
+ }
39
+ }
40
+ async function takeScreenshot(params) {
41
+ const usecomputer = await getUC();
42
+ const tmpPath = path.join("/tmp", `dispatch-ss-${Date.now()}.png`);
43
+ try {
44
+ const result = await usecomputer.screenshot({ path: tmpPath, display: null, window: null, region: null, annotate: null });
45
+ // Read the PNG and convert to JPEG via sharp
46
+ const pngBuffer = fs.readFileSync(tmpPath);
47
+ let jpegBuffer;
48
+ try {
49
+ const sharp = (await import("sharp")).default;
50
+ jpegBuffer = await sharp(pngBuffer).jpeg({ quality: 80 }).toBuffer();
51
+ }
52
+ catch {
53
+ // sharp not available — send PNG
54
+ jpegBuffer = pngBuffer;
55
+ }
56
+ return {
57
+ success: true,
58
+ screenshotBuffer: jpegBuffer,
59
+ screenshotMeta: {
60
+ width: result.imageWidth,
61
+ height: result.imageHeight,
62
+ format: "jpeg",
63
+ coordMap: result.coordMap,
64
+ },
65
+ };
66
+ }
67
+ finally {
68
+ try {
69
+ fs.unlinkSync(tmpPath);
70
+ }
71
+ catch { }
72
+ }
73
+ }
74
+ async function doClick(params) {
75
+ const usecomputer = await getUC();
76
+ const x = Number(params.x);
77
+ const y = Number(params.y);
78
+ await usecomputer.click({ point: { x, y }, button: "left", count: 1 });
79
+ return { success: true, data: { action: "click", x, y } };
80
+ }
81
+ async function doType(params) {
82
+ const usecomputer = await getUC();
83
+ const text = String(params.text);
84
+ try {
85
+ await usecomputer.typeText({ text, delayMs: null });
86
+ }
87
+ catch {
88
+ // Fallback: use xdotool on Linux if usecomputer fails (space character bug)
89
+ if (process.platform === "linux") {
90
+ execSync(`xdotool type --delay 12 -- ${JSON.stringify(text)}`, { env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" } });
91
+ }
92
+ else {
93
+ throw new Error("typeText failed");
94
+ }
95
+ }
96
+ return { success: true, data: { action: "type" } };
97
+ }
98
+ async function doPress(params) {
99
+ const usecomputer = await getUC();
100
+ const key = String(params.key);
101
+ await usecomputer.press({ key, count: null, delayMs: null });
102
+ return { success: true, data: { action: "press", key } };
103
+ }
104
+ async function doScroll(params) {
105
+ const usecomputer = await getUC();
106
+ const direction = String(params.direction);
107
+ const amount = Number(params.amount || 3);
108
+ await usecomputer.scroll({ direction, amount, at: null });
109
+ return { success: true, data: { action: "scroll", direction, amount } };
110
+ }
111
+ async function listWindows() {
112
+ const usecomputer = await getUC();
113
+ const windows = await usecomputer.windowList();
114
+ return { success: true, data: { windows } };
115
+ }
116
+ //# sourceMappingURL=executor.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @instaclaw/dispatch — Local relay for InstaClaw agents to control your computer.
4
+ *
5
+ * Usage:
6
+ * npx @instaclaw/dispatch # Interactive setup
7
+ * npx @instaclaw/dispatch --token XXX --vm HOST # Direct connect
8
+ */
9
+ import { loadConfig, saveConfig, getConfigPath } from "./config.js";
10
+ import { checkPermissions, printPermissionGuide } from "./permissions.js";
11
+ import { connect, disconnect } from "./connection.js";
12
+ import { closeSupervisor } from "./supervisor.js";
13
+ import readline from "readline";
14
+ async function main() {
15
+ console.log(`
16
+ InstaClaw Dispatch — Remote Computer Control
17
+ ─────────────────────────────────────────────
18
+ `);
19
+ // 1. Check/request permissions
20
+ const perms = checkPermissions();
21
+ if (!perms.accessibility || !perms.screenRecording) {
22
+ console.log(" Checking permissions...");
23
+ printPermissionGuide();
24
+ await waitForEnter();
25
+ }
26
+ // 2. Load or create config
27
+ let config = loadConfig();
28
+ const args = parseArgs();
29
+ if (args.token && args.vm) {
30
+ // CLI args override config
31
+ config = {
32
+ gatewayToken: args.token,
33
+ vmAddress: args.vm,
34
+ port: args.port || 8765,
35
+ mode: "supervised",
36
+ };
37
+ saveConfig(config);
38
+ }
39
+ if (!config?.gatewayToken || !config?.vmAddress) {
40
+ // Interactive setup
41
+ config = await interactiveSetup(config);
42
+ saveConfig(config);
43
+ console.log(` Config saved to ${getConfigPath()}\n`);
44
+ }
45
+ console.log(` VM: ${config.vmAddress}:${config.port}`);
46
+ console.log(` Mode: ${config.mode}`);
47
+ console.log(` Token: ${config.gatewayToken.substring(0, 8)}...\n`);
48
+ // 3. Connect to VM
49
+ connect({
50
+ vmAddress: config.vmAddress,
51
+ port: config.port,
52
+ gatewayToken: config.gatewayToken,
53
+ mode: config.mode,
54
+ rejectUnauthorized: false, // Accept self-signed (TOFU)
55
+ certFingerprint: config.certFingerprint,
56
+ onConnect: () => {
57
+ console.log(`\n Dispatch mode: SUPERVISED`);
58
+ console.log(` Screenshots auto-approved. Actions require [Enter] to confirm.`);
59
+ console.log(` Press Ctrl+C to disconnect.\n`);
60
+ },
61
+ onDisconnect: () => {
62
+ // Will auto-reconnect
63
+ },
64
+ onCertFingerprint: (fp) => {
65
+ // TOFU: save fingerprint on first connection
66
+ if (config && !config.certFingerprint) {
67
+ config.certFingerprint = fp;
68
+ saveConfig(config);
69
+ console.log(` TLS fingerprint saved (TOFU): ${fp.substring(0, 20)}...`);
70
+ }
71
+ },
72
+ });
73
+ // 4. Handle Ctrl+C
74
+ process.on("SIGINT", () => {
75
+ console.log("\n Disconnecting...");
76
+ disconnect();
77
+ closeSupervisor();
78
+ process.exit(0);
79
+ });
80
+ }
81
+ async function interactiveSetup(existing) {
82
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
+ const ask = (q, def) => new Promise((resolve) => {
84
+ const prompt = def ? `${q} [${def}]: ` : `${q}: `;
85
+ rl.question(` ${prompt}`, (ans) => resolve(ans.trim() || def || ""));
86
+ });
87
+ console.log(" First-time setup:\n");
88
+ const token = await ask("Gateway token", existing?.gatewayToken);
89
+ const vm = await ask("VM address (IP or hostname)", existing?.vmAddress);
90
+ const portStr = await ask("Port", String(existing?.port || 8765));
91
+ rl.close();
92
+ return {
93
+ gatewayToken: token,
94
+ vmAddress: vm,
95
+ port: parseInt(portStr, 10) || 8765,
96
+ mode: "supervised",
97
+ certFingerprint: existing?.certFingerprint,
98
+ };
99
+ }
100
+ function parseArgs() {
101
+ const args = process.argv.slice(2);
102
+ const result = {};
103
+ for (let i = 0; i < args.length; i++) {
104
+ if ((args[i] === "--token" || args[i] === "-t") && args[i + 1]) {
105
+ result.token = args[++i];
106
+ }
107
+ else if ((args[i] === "--vm" || args[i] === "-v") && args[i + 1]) {
108
+ result.vm = args[++i];
109
+ }
110
+ else if ((args[i] === "--port" || args[i] === "-p") && args[i + 1]) {
111
+ result.port = parseInt(args[++i], 10);
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+ function waitForEnter() {
117
+ return new Promise((resolve) => {
118
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
119
+ rl.question("", () => { rl.close(); resolve(); });
120
+ });
121
+ }
122
+ main().catch((err) => {
123
+ console.error("Fatal:", err);
124
+ process.exit(1);
125
+ });
126
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ interface PermissionStatus {
2
+ accessibility: boolean;
3
+ screenRecording: boolean;
4
+ platform: string;
5
+ }
6
+ export declare function checkPermissions(): PermissionStatus;
7
+ export declare function printPermissionGuide(): void;
8
+ export {};
@@ -0,0 +1,44 @@
1
+ import os from "os";
2
+ export function checkPermissions() {
3
+ const platform = os.platform();
4
+ if (platform === "darwin") {
5
+ return checkMacPermissions();
6
+ }
7
+ // Linux/Windows — no special permissions needed for X11
8
+ return { accessibility: true, screenRecording: true, platform };
9
+ }
10
+ function checkMacPermissions() {
11
+ let accessibility = false;
12
+ let screenRecording = false;
13
+ // Check accessibility: try to create a CGEvent (will fail without permission)
14
+ // We can't check directly from Node — usecomputer will fail at runtime if missing
15
+ // For now, assume true and catch errors at execution time
16
+ accessibility = true; // Will validate on first command
17
+ // Screen recording: same — can't check from Node without native module
18
+ screenRecording = true;
19
+ return { accessibility, screenRecording, platform: "darwin" };
20
+ }
21
+ export function printPermissionGuide() {
22
+ const platform = os.platform();
23
+ if (platform === "darwin") {
24
+ console.log(`
25
+ To grant permissions (macOS):
26
+ 1. System Settings → Privacy & Security → Accessibility
27
+ → Add your terminal app (Terminal, iTerm, Warp, etc.)
28
+ 2. System Settings → Privacy & Security → Screen Recording
29
+ → Add your terminal app
30
+
31
+ Press Enter after granting permissions...`);
32
+ }
33
+ else if (platform === "linux") {
34
+ console.log(`
35
+ Linux: No special permissions needed.
36
+ Ensure DISPLAY is set if using X11.`);
37
+ }
38
+ else {
39
+ console.log(`
40
+ Windows: No special permissions needed.
41
+ Run as administrator if controlling elevated apps.`);
42
+ }
43
+ }
44
+ //# sourceMappingURL=permissions.js.map
@@ -0,0 +1,3 @@
1
+ import type { DispatchCommand } from "./types.js";
2
+ export declare function requestApproval(command: DispatchCommand): Promise<boolean>;
3
+ export declare function closeSupervisor(): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Supervisor — prompts user for approval before executing commands.
3
+ * Screenshots are auto-approved. All other actions require Enter to proceed.
4
+ */
5
+ import readline from "readline";
6
+ const AUTO_APPROVE_TYPES = new Set(["screenshot", "windows", "status"]);
7
+ const isTTY = process.stdin.isTTY ?? false;
8
+ let rl = null;
9
+ if (isTTY) {
10
+ rl = readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout,
13
+ });
14
+ }
15
+ export async function requestApproval(command) {
16
+ // Auto-approve passive commands
17
+ if (AUTO_APPROVE_TYPES.has(command.type)) {
18
+ return true;
19
+ }
20
+ const desc = formatCommand(command);
21
+ console.log(`\n Agent wants to: ${desc}`);
22
+ // Non-interactive mode (no TTY) — auto-approve with log
23
+ if (!rl) {
24
+ console.log(" Auto-approved (non-interactive mode)");
25
+ return true;
26
+ }
27
+ return new Promise((resolve) => {
28
+ rl.question(" [Enter] to approve, [n] to deny: ", (answer) => {
29
+ const denied = answer.trim().toLowerCase() === "n";
30
+ if (denied) {
31
+ console.log(" Denied.");
32
+ }
33
+ resolve(!denied);
34
+ });
35
+ });
36
+ }
37
+ function formatCommand(cmd) {
38
+ const p = cmd.params;
39
+ switch (cmd.type) {
40
+ case "click":
41
+ return `Click at (${p.x}, ${p.y})${cmd.description ? ` — "${cmd.description}"` : ""}`;
42
+ case "type":
43
+ return `Type: "${String(p.text).substring(0, 50)}${String(p.text).length > 50 ? "..." : ""}"`;
44
+ case "press":
45
+ return `Press key: ${p.key}`;
46
+ case "scroll":
47
+ return `Scroll ${p.direction} by ${p.amount || 3}`;
48
+ case "drag":
49
+ return `Drag from (${p.fromX}, ${p.fromY}) to (${p.toX}, ${p.toY})`;
50
+ default:
51
+ return `${cmd.type}: ${JSON.stringify(p).substring(0, 80)}`;
52
+ }
53
+ }
54
+ export function closeSupervisor() {
55
+ rl?.close();
56
+ }
57
+ //# sourceMappingURL=supervisor.js.map
@@ -0,0 +1,34 @@
1
+ /** Command sent from VM agent → dispatch server → local relay */
2
+ export interface DispatchCommand {
3
+ id: string;
4
+ type: "screenshot" | "click" | "type" | "press" | "scroll" | "drag" | "windows" | "status";
5
+ params: Record<string, unknown>;
6
+ description?: string;
7
+ }
8
+ /** Result sent from local relay → dispatch server → agent */
9
+ export interface DispatchResult {
10
+ id: string;
11
+ type: "result" | "screenshot_result" | "error";
12
+ success: boolean;
13
+ data?: Record<string, unknown>;
14
+ error?: string;
15
+ }
16
+ /** Screenshot metadata (sent as text frame before binary frame) */
17
+ export interface ScreenshotMeta {
18
+ id: string;
19
+ type: "screenshot_result";
20
+ width: number;
21
+ height: number;
22
+ format: "jpeg" | "png";
23
+ size: number;
24
+ coordMap: string;
25
+ }
26
+ /** Relay config persisted to disk */
27
+ export interface DispatchConfig {
28
+ gatewayToken: string;
29
+ vmAddress: string;
30
+ port: number;
31
+ mode: "supervised" | "autonomous";
32
+ /** TLS cert fingerprint for TOFU (trust on first use) */
33
+ certFingerprint?: string;
34
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@instaclaw/dispatch",
3
+ "version": "0.1.0",
4
+ "description": "Let your InstaClaw AI agent control your computer — screenshots, clicks, typing, and more via a secure WebSocket relay",
5
+ "bin": {
6
+ "instaclaw-dispatch": "./dist/index.js"
7
+ },
8
+ "type": "module",
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/**/*.d.ts"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build",
16
+ "start": "node dist/index.js"
17
+ },
18
+ "keywords": [
19
+ "instaclaw",
20
+ "ai-agent",
21
+ "computer-use",
22
+ "remote-control",
23
+ "dispatch",
24
+ "usecomputer"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/coopergwrenn/clawlancer.git",
29
+ "directory": "instaclaw/packages/dispatch"
30
+ },
31
+ "homepage": "https://instaclaw.io",
32
+ "dependencies": {
33
+ "usecomputer": "^0.1.4",
34
+ "ws": "^8.18.0",
35
+ "sharp": "^0.33.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/ws": "^8.5.0",
39
+ "@types/node": "^22.0.0",
40
+ "typescript": "^5.7.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "license": "MIT",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }