@leg3ndy/otto-bridge 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.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Otto Bridge
2
+
3
+ Companion local do Otto para:
4
+
5
+ - reivindicar um codigo de pareamento gerado pela web
6
+ - armazenar o `device_token` do dispositivo
7
+ - manter um WebSocket persistente com o backend
8
+ - executar jobs locais com `mock` ou `clawd-cursor`
9
+
10
+ ## Distribuicao
11
+
12
+ Fluxo recomendado agora:
13
+
14
+ 1. publicar como pacote npm privado/publico para time interno e beta testers
15
+ 2. validar pareamento, atualizacao e telemetria em ambiente real
16
+ 3. depois empacotar em `.dmg/.pkg` no macOS e `.msi` no Windows
17
+
18
+ O pacote ja esta estruturado para install via CLI:
19
+
20
+ ```bash
21
+ npm install -g @leg3ndy/otto-bridge
22
+ otto-bridge status
23
+ ```
24
+
25
+ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
26
+
27
+ ```bash
28
+ npm pack
29
+ npm install -g ./otto-bridge-0.1.0.tgz
30
+ ```
31
+
32
+ ## Publicacao
33
+
34
+ Checklist de release:
35
+
36
+ ```bash
37
+ npm whoami
38
+ npm install
39
+ npm run release:check
40
+ npm publish --access public
41
+ ```
42
+
43
+ O publish exige permissao no scope `@leg3ndy`.
44
+
45
+ Se o seu `npm` local estiver com cache travado por permissao, rode com cache temporario:
46
+
47
+ ```bash
48
+ npm_config_cache=/tmp/otto-npm-cache npm run release:check
49
+ npm_config_cache=/tmp/otto-npm-cache npm publish --access public
50
+ ```
51
+
52
+ ## Comandos
53
+
54
+ ### Parear o dispositivo
55
+
56
+ ```bash
57
+ otto-bridge pair --api http://localhost:8000 --code ABC123 --executor clawd-cursor
58
+ ```
59
+
60
+ Opcoes suportadas:
61
+
62
+ - `--name`: nome amigavel do dispositivo
63
+ - `--timeout-seconds`: limite de espera pela aprovacao web
64
+ - `--poll-interval-ms`: intervalo de polling do claim
65
+ - `--executor`: `mock` ou `clawd-cursor`
66
+ - `--clawd-url`: base URL da API local do `clawd-cursor`
67
+ - `--clawd-poll-interval-ms`: polling do status/logs do `clawd-cursor`
68
+
69
+ ### Rodar o bridge
70
+
71
+ ```bash
72
+ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
73
+ ```
74
+
75
+ Se o executor estiver salvo no `config.json`, o `run` usa essa configuracao por padrao.
76
+
77
+ ### Ver estado local
78
+
79
+ ```bash
80
+ otto-bridge status
81
+ ```
82
+
83
+ ### Remover pareamento local
84
+
85
+ ```bash
86
+ otto-bridge unpair
87
+ ```
88
+
89
+ ## Variaveis de ambiente
90
+
91
+ - `OTTO_API_BASE_URL`
92
+ - `OTTO_BRIDGE_HOME`
93
+ - `OTTO_BRIDGE_NAME`
94
+ - `OTTO_BRIDGE_EXECUTOR`
95
+ - `OTTO_CLAWD_BASE_URL`
96
+ - `OTTO_CLAWD_POLL_INTERVAL_MS`
97
+
98
+ ## Payload esperado para jobs desktop
99
+
100
+ O adapter do `clawd-cursor` procura a tarefa em uma destas chaves, nessa ordem:
101
+
102
+ - `task`
103
+ - `prompt`
104
+ - `instruction`
105
+ - `instructions`
106
+ - `message`
107
+
108
+ Exemplo:
109
+
110
+ ```json
111
+ {
112
+ "job_type": "desktop_task",
113
+ "payload": {
114
+ "task": "Abra o Safari e pesquise Otto AI"
115
+ }
116
+ }
117
+ ```
package/dist/config.js ADDED
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname, platform, arch } from "node:os";
4
+ import path from "node:path";
5
+ import { BRIDGE_CONFIG_VERSION, BRIDGE_VERSION, DEFAULT_CLAWD_CURSOR_BASE_URL, DEFAULT_CLAWD_CURSOR_POLL_INTERVAL_MS, DEFAULT_API_BASE_URL, DEFAULT_EXECUTOR_TYPE, } from "./types.js";
6
+ function sanitizeApiBaseUrl(value) {
7
+ const raw = String(value || DEFAULT_API_BASE_URL).trim();
8
+ if (!raw) {
9
+ return DEFAULT_API_BASE_URL;
10
+ }
11
+ return raw.replace(/\/+$/, "");
12
+ }
13
+ function sanitizeExecutorType(value) {
14
+ return value === "clawd-cursor" ? "clawd-cursor" : DEFAULT_EXECUTOR_TYPE;
15
+ }
16
+ function sanitizeClawdCursorBaseUrl(value) {
17
+ const raw = String(value || DEFAULT_CLAWD_CURSOR_BASE_URL).trim();
18
+ if (!raw) {
19
+ return DEFAULT_CLAWD_CURSOR_BASE_URL;
20
+ }
21
+ return raw.replace(/\/+$/, "");
22
+ }
23
+ function sanitizePollIntervalMs(value, fallback = DEFAULT_CLAWD_CURSOR_POLL_INTERVAL_MS) {
24
+ const parsed = Number(value ?? fallback);
25
+ if (!Number.isFinite(parsed)) {
26
+ return fallback;
27
+ }
28
+ return Math.max(250, Math.floor(parsed));
29
+ }
30
+ export function getBridgeHomeDir() {
31
+ const custom = String(process.env.OTTO_BRIDGE_HOME || "").trim();
32
+ return custom || path.join(homedir(), ".otto-bridge");
33
+ }
34
+ export function getBridgeConfigPath() {
35
+ return path.join(getBridgeHomeDir(), "config.json");
36
+ }
37
+ export async function ensureBridgeHomeDir() {
38
+ await mkdir(getBridgeHomeDir(), { recursive: true });
39
+ }
40
+ export async function loadBridgeConfig() {
41
+ try {
42
+ const raw = await readFile(getBridgeConfigPath(), "utf8");
43
+ const parsed = JSON.parse(raw);
44
+ if (!parsed || typeof parsed !== "object") {
45
+ return null;
46
+ }
47
+ if (!parsed.deviceToken || !parsed.deviceId) {
48
+ return null;
49
+ }
50
+ return {
51
+ ...parsed,
52
+ apiBaseUrl: sanitizeApiBaseUrl(parsed.apiBaseUrl),
53
+ wsUrl: buildWebSocketUrl(parsed.apiBaseUrl),
54
+ executor: resolveExecutorConfig(undefined, parsed.executor),
55
+ };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export async function saveBridgeConfig(config) {
62
+ await ensureBridgeHomeDir();
63
+ await writeFile(getBridgeConfigPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
64
+ }
65
+ export async function clearBridgeConfig() {
66
+ await rm(getBridgeConfigPath(), { force: true });
67
+ }
68
+ export function resolveApiBaseUrl(explicit) {
69
+ return sanitizeApiBaseUrl(explicit || process.env.OTTO_API_BASE_URL || DEFAULT_API_BASE_URL);
70
+ }
71
+ export function resolveExecutorConfig(overrides, current) {
72
+ const currentType = sanitizeExecutorType(current?.type);
73
+ const type = sanitizeExecutorType(overrides?.type ?? currentType ?? process.env.OTTO_BRIDGE_EXECUTOR);
74
+ if (type === "clawd-cursor") {
75
+ const currentClawd = current?.type === "clawd-cursor" ? current : null;
76
+ return {
77
+ type: "clawd-cursor",
78
+ baseUrl: sanitizeClawdCursorBaseUrl(overrides?.clawdBaseUrl
79
+ ?? currentClawd?.baseUrl
80
+ ?? process.env.OTTO_CLAWD_BASE_URL),
81
+ pollIntervalMs: sanitizePollIntervalMs(overrides?.clawdPollIntervalMs
82
+ ?? currentClawd?.pollIntervalMs
83
+ ?? process.env.OTTO_CLAWD_POLL_INTERVAL_MS),
84
+ };
85
+ }
86
+ return { type: "mock" };
87
+ }
88
+ export function buildWebSocketUrl(apiBaseUrl) {
89
+ const url = new URL(resolveApiBaseUrl(apiBaseUrl));
90
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
91
+ url.pathname = "/v1/devices/ws";
92
+ url.search = "";
93
+ return url.toString();
94
+ }
95
+ export function defaultDeviceName() {
96
+ const envName = String(process.env.OTTO_BRIDGE_NAME || "").trim();
97
+ if (envName) {
98
+ return envName;
99
+ }
100
+ return `Otto Bridge - ${hostname()}`;
101
+ }
102
+ export function buildDeviceMetadata() {
103
+ return {
104
+ host: hostname(),
105
+ arch: arch(),
106
+ node: process.version,
107
+ runtime: "node",
108
+ };
109
+ }
110
+ export function buildDeviceFingerprint() {
111
+ const input = `${hostname()}|${platform()}|${arch()}|${process.version}`;
112
+ return createHash("sha256").update(input).digest("hex");
113
+ }
114
+ export function buildBridgeConfig(params) {
115
+ const apiBaseUrl = resolveApiBaseUrl(params.apiBaseUrl);
116
+ return {
117
+ version: BRIDGE_CONFIG_VERSION,
118
+ apiBaseUrl,
119
+ wsUrl: buildWebSocketUrl(apiBaseUrl),
120
+ deviceId: params.deviceId,
121
+ deviceToken: params.deviceToken,
122
+ deviceName: params.deviceName,
123
+ platform: platform(),
124
+ bridgeVersion: BRIDGE_VERSION,
125
+ approvalMode: params.approvalMode || "preview",
126
+ capabilities: Array.isArray(params.capabilities) ? [...params.capabilities] : [],
127
+ metadata: params.metadata || {},
128
+ pairedAt: new Date().toISOString(),
129
+ executor: resolveExecutorConfig(undefined, params.executor),
130
+ };
131
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,236 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { getJson, postJson } from "../http.js";
3
+ const LOG_LIMIT = 200;
4
+ const COMPLETION_SETTLE_TIMEOUT_MS = 4000;
5
+ const COMPLETION_SETTLE_INTERVAL_MS = 500;
6
+ function asRecord(value) {
7
+ return value && typeof value === "object" ? value : {};
8
+ }
9
+ function asString(value) {
10
+ if (typeof value !== "string") {
11
+ return null;
12
+ }
13
+ const trimmed = value.trim();
14
+ return trimmed || null;
15
+ }
16
+ function asNumber(value) {
17
+ const parsed = Number(value);
18
+ if (!Number.isFinite(parsed)) {
19
+ return null;
20
+ }
21
+ return parsed;
22
+ }
23
+ function clampPercent(value) {
24
+ return Math.max(0, Math.min(100, Math.round(value)));
25
+ }
26
+ function extractTaskText(job) {
27
+ const payload = asRecord(job.payload);
28
+ const candidates = [
29
+ payload.task,
30
+ payload.prompt,
31
+ payload.instruction,
32
+ payload.instructions,
33
+ payload.message,
34
+ ];
35
+ for (const candidate of candidates) {
36
+ const text = asString(candidate);
37
+ if (text) {
38
+ return text;
39
+ }
40
+ }
41
+ const serializedPayload = JSON.stringify(payload);
42
+ if (serializedPayload && serializedPayload !== "{}") {
43
+ return `Execute Otto job ${job.job_type} with payload: ${serializedPayload}`;
44
+ }
45
+ return `Execute Otto job ${job.job_type}`;
46
+ }
47
+ function parseLogTimestamp(entry) {
48
+ const timestamp = asString(entry.timestamp);
49
+ if (!timestamp) {
50
+ return 0;
51
+ }
52
+ const parsed = Date.parse(timestamp);
53
+ return Number.isFinite(parsed) ? parsed : 0;
54
+ }
55
+ function filterLogsSince(logs, startedAtMs) {
56
+ return logs.filter((entry) => parseLogTimestamp(entry) >= startedAtMs - 1000);
57
+ }
58
+ function summarizeLog(entry, fallback) {
59
+ const message = asString(entry?.message);
60
+ if (!message) {
61
+ return fallback;
62
+ }
63
+ return message.length > 240 ? `${message.slice(0, 237)}...` : message;
64
+ }
65
+ function buildStatusMessage(state, fallbackTask) {
66
+ const currentStep = asString(state.currentStep);
67
+ if (currentStep) {
68
+ return currentStep;
69
+ }
70
+ const currentTask = asString(state.currentTask);
71
+ if (currentTask) {
72
+ return currentTask;
73
+ }
74
+ const status = asString(state.status);
75
+ if (status && status !== "idle") {
76
+ return `Executor status: ${status}`;
77
+ }
78
+ return fallbackTask;
79
+ }
80
+ function buildProgressPercent(state, fallbackStatus, lastProgressPercent) {
81
+ const stepsCompleted = asNumber(state.stepsCompleted);
82
+ const stepsTotal = asNumber(state.stepsTotal);
83
+ if (stepsCompleted !== null && stepsTotal !== null && stepsTotal > 0) {
84
+ return clampPercent(Math.max(lastProgressPercent, (stepsCompleted / stepsTotal) * 100));
85
+ }
86
+ switch (fallbackStatus) {
87
+ case "thinking":
88
+ return Math.max(lastProgressPercent, 15);
89
+ case "acting":
90
+ return Math.max(lastProgressPercent, 55);
91
+ case "waiting_confirm":
92
+ return Math.max(lastProgressPercent, 75);
93
+ default:
94
+ return lastProgressPercent;
95
+ }
96
+ }
97
+ function stateStatus(state) {
98
+ return String(state.status || "").trim().toLowerCase();
99
+ }
100
+ export class ClawdCursorJobExecutor {
101
+ config;
102
+ constructor(config) {
103
+ this.config = config;
104
+ }
105
+ async run(job, reporter) {
106
+ const task = extractTaskText(job);
107
+ const startedAtMs = Date.now();
108
+ let taskAccepted = false;
109
+ let lastProgressKey = "";
110
+ let lastProgressPercent = 0;
111
+ let sawActiveState = false;
112
+ try {
113
+ await this.assertHealthy();
114
+ const taskResponse = await postJson(this.config.baseUrl, "/task", { task });
115
+ if (taskResponse.ok !== true) {
116
+ throw new Error("Clawd Cursor rejected task execution");
117
+ }
118
+ taskAccepted = true;
119
+ const taskId = asString(taskResponse.task_id) || "";
120
+ await reporter.accepted();
121
+ while (true) {
122
+ const statusResponse = await getJson(this.config.baseUrl, "/status");
123
+ const state = asRecord(statusResponse.state);
124
+ const status = stateStatus(state);
125
+ const progressMessage = buildStatusMessage(state, task);
126
+ const progressPercent = buildProgressPercent(state, status, lastProgressPercent);
127
+ if (status && status !== "idle") {
128
+ sawActiveState = true;
129
+ }
130
+ if (status === "waiting_confirm") {
131
+ const decision = await reporter.confirmRequired(progressMessage, {
132
+ executor: "clawd-cursor",
133
+ task_id: taskId,
134
+ task,
135
+ state,
136
+ });
137
+ await postJson(this.config.baseUrl, "/confirm", {
138
+ approved: decision.action === "approve",
139
+ });
140
+ if (decision.action === "reject") {
141
+ return;
142
+ }
143
+ await sleep(this.config.pollIntervalMs);
144
+ continue;
145
+ }
146
+ if (status !== "idle") {
147
+ const progressKey = `${status}:${progressPercent}:${progressMessage}`;
148
+ if (progressKey !== lastProgressKey) {
149
+ await reporter.progress(progressPercent, progressMessage);
150
+ lastProgressKey = progressKey;
151
+ lastProgressPercent = progressPercent;
152
+ }
153
+ await sleep(this.config.pollIntervalMs);
154
+ continue;
155
+ }
156
+ if (!sawActiveState) {
157
+ await sleep(this.config.pollIntervalMs);
158
+ continue;
159
+ }
160
+ const outcome = await this.resolveOutcome(startedAtMs);
161
+ if (!outcome.ok) {
162
+ await reporter.failed(outcome.summary, {
163
+ executor: "clawd-cursor",
164
+ task_id: taskId,
165
+ logs: outcome.logs,
166
+ });
167
+ return;
168
+ }
169
+ await reporter.completed({
170
+ summary: outcome.summary,
171
+ executor: "clawd-cursor",
172
+ task_id: taskId,
173
+ logs: outcome.logs,
174
+ });
175
+ return;
176
+ }
177
+ }
178
+ catch (error) {
179
+ if (taskAccepted) {
180
+ await this.abortSilently();
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ async assertHealthy() {
186
+ const health = await getJson(this.config.baseUrl, "/health");
187
+ if (health.ok !== true) {
188
+ throw new Error("Clawd Cursor health check failed");
189
+ }
190
+ }
191
+ async resolveOutcome(startedAtMs) {
192
+ const deadline = Date.now() + COMPLETION_SETTLE_TIMEOUT_MS;
193
+ let recentLogs = [];
194
+ while (Date.now() <= deadline) {
195
+ const logsResponse = await getJson(this.config.baseUrl, `/logs?limit=${LOG_LIMIT}`);
196
+ const allLogs = Array.isArray(logsResponse.logs) ? logsResponse.logs : [];
197
+ recentLogs = filterLogsSince(allLogs, startedAtMs);
198
+ const failureLog = [...recentLogs].reverse().find((entry) => {
199
+ const message = asString(entry.message)?.toLowerCase() || "";
200
+ return message.includes("task execution failed");
201
+ }) || null;
202
+ if (failureLog) {
203
+ return {
204
+ ok: false,
205
+ summary: summarizeLog(failureLog, "Clawd Cursor task failed"),
206
+ logs: recentLogs.slice(-10),
207
+ };
208
+ }
209
+ const successLog = [...recentLogs].reverse().find((entry) => {
210
+ const message = asString(entry.message)?.toLowerCase() || "";
211
+ return message.includes("task result:");
212
+ }) || null;
213
+ if (successLog) {
214
+ return {
215
+ ok: true,
216
+ summary: summarizeLog(successLog, "Clawd Cursor task completed"),
217
+ logs: recentLogs.slice(-10),
218
+ };
219
+ }
220
+ await sleep(COMPLETION_SETTLE_INTERVAL_MS);
221
+ }
222
+ return {
223
+ ok: true,
224
+ summary: "Clawd Cursor task completed",
225
+ logs: recentLogs.slice(-10),
226
+ };
227
+ }
228
+ async abortSilently() {
229
+ try {
230
+ await postJson(this.config.baseUrl, "/abort", {});
231
+ }
232
+ catch {
233
+ // Best effort only. The Otto backend still receives the executor failure.
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,33 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ function shouldRequireConfirmation(payload) {
3
+ return payload.require_confirmation === true || payload.requireConfirmation === true;
4
+ }
5
+ export class MockJobExecutor {
6
+ async run(job, reporter) {
7
+ await reporter.accepted();
8
+ await reporter.progress(15, "Planejando execução local");
9
+ await sleep(150);
10
+ if (shouldRequireConfirmation(job.payload)) {
11
+ const decision = await reporter.confirmRequired("Aguardando confirmação do usuário para continuar a execução mock", {
12
+ step: "mock_confirmation_gate",
13
+ job_type: job.job_type,
14
+ payload_preview: job.payload,
15
+ });
16
+ if (decision.action === "reject") {
17
+ return;
18
+ }
19
+ await reporter.progress(55, "Confirmação recebida. Retomando execução mock");
20
+ }
21
+ else {
22
+ await reporter.progress(55, "Executando ação mock no dispositivo");
23
+ }
24
+ await sleep(150);
25
+ await reporter.progress(90, "Finalizando resultado");
26
+ await sleep(100);
27
+ await reporter.completed({
28
+ summary: "Mock executor finished successfully",
29
+ job_type: job.job_type,
30
+ echoed_payload: job.payload,
31
+ });
32
+ }
33
+ }
package/dist/http.js ADDED
@@ -0,0 +1,32 @@
1
+ import { DEFAULT_API_BASE_URL } from "./types.js";
2
+ function normalizeBaseUrl(apiBaseUrl) {
3
+ const raw = String(apiBaseUrl || DEFAULT_API_BASE_URL).trim();
4
+ return raw.replace(/\/+$/, "");
5
+ }
6
+ async function requestJson(apiBaseUrl, pathname, init) {
7
+ const response = await fetch(`${normalizeBaseUrl(apiBaseUrl)}${pathname}`, init);
8
+ const payload = (await response.json().catch(() => null));
9
+ if (!response.ok) {
10
+ const detail = payload && typeof payload === "object" && payload !== null
11
+ ? (("detail" in payload && typeof payload.detail === "string" && payload.detail)
12
+ || ("error" in payload && typeof payload.error === "string" && payload.error))
13
+ : null;
14
+ throw new Error(detail || `HTTP ${response.status}`);
15
+ }
16
+ if (!payload) {
17
+ throw new Error("Empty JSON response");
18
+ }
19
+ return payload;
20
+ }
21
+ export async function getJson(apiBaseUrl, pathname) {
22
+ return await requestJson(apiBaseUrl, pathname);
23
+ }
24
+ export async function postJson(apiBaseUrl, pathname, body) {
25
+ return await requestJson(apiBaseUrl, pathname, {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ },
30
+ body: JSON.stringify(body),
31
+ });
32
+ }
package/dist/main.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { clearBridgeConfig, getBridgeConfigPath, loadBridgeConfig, resolveApiBaseUrl, resolveExecutorConfig, } from "./config.js";
4
+ import { pairDevice } from "./pairing.js";
5
+ import { BridgeRuntime } from "./runtime.js";
6
+ import { DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
7
+ function parseArgs(argv) {
8
+ const [maybeCommand, ...rest] = argv;
9
+ const command = maybeCommand && !maybeCommand.startsWith("--") ? maybeCommand : "run";
10
+ const tokens = command === "run" && maybeCommand?.startsWith("--") ? argv : rest;
11
+ const options = new Map();
12
+ for (let i = 0; i < tokens.length; i += 1) {
13
+ const token = tokens[i];
14
+ if (!token.startsWith("--")) {
15
+ continue;
16
+ }
17
+ const key = token.slice(2);
18
+ const next = tokens[i + 1];
19
+ if (!next || next.startsWith("--")) {
20
+ options.set(key, true);
21
+ continue;
22
+ }
23
+ options.set(key, next);
24
+ i += 1;
25
+ }
26
+ return { command, options };
27
+ }
28
+ function option(args, name) {
29
+ const value = args.options.get(name);
30
+ return typeof value === "string" ? value : undefined;
31
+ }
32
+ function numberOption(args, name) {
33
+ const value = option(args, name);
34
+ if (value === undefined) {
35
+ return undefined;
36
+ }
37
+ const parsed = Number(value);
38
+ return Number.isFinite(parsed) ? parsed : undefined;
39
+ }
40
+ function resolveExecutorOverrides(args, current) {
41
+ return resolveExecutorConfig({
42
+ type: option(args, "executor") || process.env.OTTO_BRIDGE_EXECUTOR,
43
+ clawdBaseUrl: option(args, "clawd-url") || process.env.OTTO_CLAWD_BASE_URL,
44
+ clawdPollIntervalMs: numberOption(args, "clawd-poll-interval-ms") ?? process.env.OTTO_CLAWD_POLL_INTERVAL_MS,
45
+ }, current);
46
+ }
47
+ function printUsage() {
48
+ console.log(`Usage:
49
+ otto-bridge pair --api http://localhost:8000 --code ABC123 [--name "Meu PC"] [--executor mock|clawd-cursor]
50
+ otto-bridge run [--executor mock|clawd-cursor] [--clawd-url http://127.0.0.1:3847]
51
+ otto-bridge status
52
+ otto-bridge unpair`);
53
+ }
54
+ async function runPairCommand(args) {
55
+ const code = option(args, "code");
56
+ if (!code) {
57
+ throw new Error("Missing required --code for pair command");
58
+ }
59
+ const apiBaseUrl = resolveApiBaseUrl(option(args, "api"));
60
+ const config = await pairDevice({
61
+ apiBaseUrl,
62
+ pairingCode: code,
63
+ deviceName: option(args, "name"),
64
+ timeoutSeconds: Number(option(args, "timeout-seconds") || DEFAULT_PAIR_TIMEOUT_SECONDS),
65
+ pollIntervalMs: Number(option(args, "poll-interval-ms") || DEFAULT_POLL_INTERVAL_MS),
66
+ executor: resolveExecutorOverrides(args),
67
+ });
68
+ console.log(`[otto-bridge] paired device=${config.deviceId}`);
69
+ console.log(`[otto-bridge] executor=${config.executor.type}`);
70
+ console.log(`[otto-bridge] config=${getBridgeConfigPath()}`);
71
+ }
72
+ async function runRuntimeCommand(args) {
73
+ const config = await loadBridgeConfig();
74
+ if (!config) {
75
+ throw new Error("No local pairing found. Run `otto-bridge pair --code <CODE>` first.");
76
+ }
77
+ const runtimeConfig = {
78
+ ...config,
79
+ executor: resolveExecutorOverrides(args, config.executor),
80
+ };
81
+ const runtime = new BridgeRuntime(runtimeConfig);
82
+ await runtime.start();
83
+ }
84
+ async function runStatusCommand() {
85
+ const config = await loadBridgeConfig();
86
+ if (!config) {
87
+ console.log("[otto-bridge] not paired");
88
+ console.log(`[otto-bridge] expected config path=${getBridgeConfigPath()}`);
89
+ return;
90
+ }
91
+ console.log(JSON.stringify({
92
+ paired: true,
93
+ config_path: getBridgeConfigPath(),
94
+ device_id: config.deviceId,
95
+ device_name: config.deviceName,
96
+ api_base_url: config.apiBaseUrl,
97
+ ws_url: config.wsUrl,
98
+ approval_mode: config.approvalMode,
99
+ capabilities: config.capabilities,
100
+ paired_at: config.pairedAt,
101
+ executor: config.executor,
102
+ }, null, 2));
103
+ }
104
+ async function runUnpairCommand() {
105
+ await clearBridgeConfig();
106
+ console.log("[otto-bridge] local pairing cleared");
107
+ }
108
+ async function main() {
109
+ const args = parseArgs(process.argv.slice(2));
110
+ switch (args.command) {
111
+ case "pair":
112
+ await runPairCommand(args);
113
+ return;
114
+ case "run":
115
+ await runRuntimeCommand(args);
116
+ return;
117
+ case "status":
118
+ await runStatusCommand();
119
+ return;
120
+ case "unpair":
121
+ await runUnpairCommand();
122
+ return;
123
+ case "help":
124
+ case "--help":
125
+ case "-h":
126
+ printUsage();
127
+ return;
128
+ default:
129
+ printUsage();
130
+ throw new Error(`Unknown command: ${args.command}`);
131
+ }
132
+ }
133
+ main().catch((error) => {
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ console.error(`[otto-bridge] ${message}`);
136
+ process.exitCode = 1;
137
+ });
@@ -0,0 +1,56 @@
1
+ import { platform } from "node:os";
2
+ import { buildBridgeConfig, buildDeviceFingerprint, buildDeviceMetadata, defaultDeviceName, resolveApiBaseUrl, saveBridgeConfig, } from "./config.js";
3
+ import { postJson } from "./http.js";
4
+ import { BRIDGE_VERSION, DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ export async function pairDevice(options) {
9
+ const apiBaseUrl = resolveApiBaseUrl(options.apiBaseUrl);
10
+ const deviceName = String(options.deviceName || defaultDeviceName()).trim() || defaultDeviceName();
11
+ const timeoutMs = Math.max(15, Number(options.timeoutSeconds || DEFAULT_PAIR_TIMEOUT_SECONDS)) * 1000;
12
+ const pollIntervalMs = Math.max(1000, Number(options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS));
13
+ const claimResponse = await postJson(apiBaseUrl, "/v1/devices/pairing/claim", {
14
+ code: options.pairingCode,
15
+ device_name: deviceName,
16
+ platform: platform(),
17
+ bridge_version: BRIDGE_VERSION,
18
+ device_fingerprint: buildDeviceFingerprint(),
19
+ metadata: buildDeviceMetadata(),
20
+ });
21
+ const claim = claimResponse.claim;
22
+ if (!claim?.claim_id || !claim.claim_token) {
23
+ throw new Error("Pairing claim response missing claim token");
24
+ }
25
+ const deadline = Date.now() + timeoutMs;
26
+ while (Date.now() <= deadline) {
27
+ const pollResponse = await postJson(apiBaseUrl, "/v1/devices/pairing/poll", {
28
+ claim_id: claim.claim_id,
29
+ claim_token: claim.claim_token,
30
+ });
31
+ const currentClaim = pollResponse.claim;
32
+ if (currentClaim.status === "approved") {
33
+ if (!currentClaim.device_id || !currentClaim.device_token) {
34
+ throw new Error("Approved claim missing device credentials");
35
+ }
36
+ const config = buildBridgeConfig({
37
+ apiBaseUrl,
38
+ deviceId: currentClaim.device_id,
39
+ deviceToken: currentClaim.device_token,
40
+ deviceName: currentClaim.device_name || deviceName,
41
+ approvalMode: currentClaim.approval_mode,
42
+ capabilities: currentClaim.capabilities,
43
+ metadata: currentClaim.metadata,
44
+ executor: options.executor,
45
+ });
46
+ await saveBridgeConfig(config);
47
+ return config;
48
+ }
49
+ if (currentClaim.status === "rejected") {
50
+ const reason = currentClaim.reason ? `: ${currentClaim.reason}` : "";
51
+ throw new Error(`Pairing rejected${reason}`);
52
+ }
53
+ await sleep(pollIntervalMs);
54
+ }
55
+ throw new Error("Timed out waiting for pairing approval");
56
+ }
@@ -0,0 +1,254 @@
1
+ import { DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_RECONNECT_BASE_DELAY_MS, DEFAULT_RECONNECT_MAX_DELAY_MS, } from "./types.js";
2
+ import { ClawdCursorJobExecutor } from "./executors/clawd_cursor.js";
3
+ import { MockJobExecutor } from "./executors/mock.js";
4
+ function delay(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ async function parseSocketMessage(data) {
8
+ if (typeof data === "string") {
9
+ return JSON.parse(data);
10
+ }
11
+ if (data instanceof Blob) {
12
+ return JSON.parse(await data.text());
13
+ }
14
+ if (data instanceof ArrayBuffer) {
15
+ return JSON.parse(Buffer.from(data).toString("utf8"));
16
+ }
17
+ if (ArrayBuffer.isView(data)) {
18
+ return JSON.parse(Buffer.from(data.buffer).toString("utf8"));
19
+ }
20
+ return data;
21
+ }
22
+ export class BridgeRuntime {
23
+ config;
24
+ reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
25
+ executor;
26
+ pendingConfirmations = new Map();
27
+ constructor(config, executor) {
28
+ this.config = config;
29
+ this.executor = executor ?? this.createDefaultExecutor(config);
30
+ }
31
+ async start() {
32
+ console.log(`[otto-bridge] runtime start device=${this.config.deviceId}`);
33
+ while (true) {
34
+ try {
35
+ await this.connectOnce();
36
+ this.reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ console.error(`[otto-bridge] socket error: ${message}`);
41
+ }
42
+ console.log(`[otto-bridge] reconnecting in ${this.reconnectDelayMs}ms`);
43
+ await delay(this.reconnectDelayMs);
44
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, DEFAULT_RECONNECT_MAX_DELAY_MS);
45
+ }
46
+ }
47
+ async connectOnce() {
48
+ const socket = new WebSocket(this.config.wsUrl, ["device", this.config.deviceToken]);
49
+ let heartbeatTimer = null;
50
+ const stopHeartbeat = () => {
51
+ if (heartbeatTimer) {
52
+ clearInterval(heartbeatTimer);
53
+ heartbeatTimer = null;
54
+ }
55
+ };
56
+ const rejectPendingConfirmations = (error) => {
57
+ for (const [jobId, waiter] of this.pendingConfirmations.entries()) {
58
+ waiter.reject(error);
59
+ this.pendingConfirmations.delete(jobId);
60
+ }
61
+ };
62
+ return await new Promise((resolve, reject) => {
63
+ socket.addEventListener("open", () => {
64
+ console.log(`[otto-bridge] connected ws=${this.config.wsUrl}`);
65
+ socket.send(JSON.stringify({
66
+ type: "device.hello",
67
+ device_id: this.config.deviceId,
68
+ device_name: this.config.deviceName,
69
+ bridge_version: this.config.bridgeVersion,
70
+ capabilities: this.config.capabilities,
71
+ metadata: this.config.metadata,
72
+ }));
73
+ heartbeatTimer = setInterval(() => {
74
+ if (socket.readyState === WebSocket.OPEN) {
75
+ socket.send(JSON.stringify({
76
+ type: "device.heartbeat",
77
+ device_id: this.config.deviceId,
78
+ sent_at: Date.now(),
79
+ }));
80
+ }
81
+ }, DEFAULT_HEARTBEAT_INTERVAL_MS);
82
+ });
83
+ socket.addEventListener("message", async (event) => {
84
+ try {
85
+ const message = await parseSocketMessage(event.data);
86
+ await this.handleMessage(socket, message);
87
+ }
88
+ catch (error) {
89
+ const detail = error instanceof Error ? error.message : String(error);
90
+ console.error(`[otto-bridge] invalid message: ${detail}`);
91
+ }
92
+ });
93
+ socket.addEventListener("close", (event) => {
94
+ stopHeartbeat();
95
+ rejectPendingConfirmations(new Error("WebSocket closed while awaiting confirmation"));
96
+ console.log(`[otto-bridge] socket closed code=${event.code}`);
97
+ resolve();
98
+ });
99
+ socket.addEventListener("error", () => {
100
+ stopHeartbeat();
101
+ rejectPendingConfirmations(new Error("WebSocket failed while awaiting confirmation"));
102
+ try {
103
+ socket.close();
104
+ }
105
+ catch {
106
+ // no-op
107
+ }
108
+ reject(new Error("WebSocket connection failed"));
109
+ });
110
+ });
111
+ }
112
+ async handleMessage(socket, message) {
113
+ const type = String(message.type || "");
114
+ switch (type) {
115
+ case "device.hello":
116
+ console.log(`[otto-bridge] server hello device=${String(message.device_id || "")}`);
117
+ return;
118
+ case "device.hello_ack":
119
+ case "device.heartbeat_ack":
120
+ return;
121
+ case "device.job.start":
122
+ console.log(`[otto-bridge] job start payload=${JSON.stringify(message)}`);
123
+ this.executeJob(socket, {
124
+ job_id: String(message.job_id || ""),
125
+ device_id: String(message.device_id || ""),
126
+ job_type: String(message.job_type || "task"),
127
+ payload: (message.payload && typeof message.payload === "object")
128
+ ? message.payload
129
+ : {},
130
+ }).catch((error) => {
131
+ const detail = error instanceof Error ? error.message : String(error);
132
+ console.error(`[otto-bridge] executor error: ${detail}`);
133
+ });
134
+ return;
135
+ case "device.job.confirmation":
136
+ this.resolveConfirmation(message);
137
+ return;
138
+ default:
139
+ console.log(`[otto-bridge] event=${type || "unknown"} payload=${JSON.stringify(message)}`);
140
+ }
141
+ }
142
+ resolveConfirmation(message) {
143
+ const jobId = String(message.job_id || "");
144
+ const action = String(message.action || "").trim().toLowerCase();
145
+ const waiter = this.pendingConfirmations.get(jobId);
146
+ if (!jobId || !waiter) {
147
+ console.warn(`[otto-bridge] unexpected confirmation payload=${JSON.stringify(message)}`);
148
+ return;
149
+ }
150
+ if (action !== "approve" && action !== "reject") {
151
+ waiter.reject(new Error(`Unsupported confirmation action: ${action || "unknown"}`));
152
+ this.pendingConfirmations.delete(jobId);
153
+ return;
154
+ }
155
+ this.pendingConfirmations.delete(jobId);
156
+ waiter.resolve({
157
+ action,
158
+ note: typeof message.note === "string" ? message.note : undefined,
159
+ });
160
+ }
161
+ async waitForConfirmation(jobId) {
162
+ return await new Promise((resolve, reject) => {
163
+ this.pendingConfirmations.set(jobId, { resolve, reject });
164
+ });
165
+ }
166
+ async executeJob(socket, job) {
167
+ const sendJson = async (payload) => {
168
+ if (socket.readyState !== WebSocket.OPEN) {
169
+ throw new Error("Socket is not open");
170
+ }
171
+ socket.send(JSON.stringify(payload));
172
+ };
173
+ try {
174
+ await this.executor.run(job, {
175
+ accepted: async () => {
176
+ await sendJson({
177
+ type: "device.job.accepted",
178
+ device_id: this.config.deviceId,
179
+ job_id: job.job_id,
180
+ accepted_at: Date.now(),
181
+ });
182
+ },
183
+ progress: async (progressPercent, progressMessage) => {
184
+ await sendJson({
185
+ type: "device.job.progress",
186
+ device_id: this.config.deviceId,
187
+ job_id: job.job_id,
188
+ progress_percent: progressPercent,
189
+ progress_message: progressMessage,
190
+ });
191
+ },
192
+ confirmRequired: async (progressMessage, confirmationContext) => {
193
+ const confirmationPromise = this.waitForConfirmation(job.job_id);
194
+ try {
195
+ await sendJson({
196
+ type: "device.job.confirm_required",
197
+ device_id: this.config.deviceId,
198
+ job_id: job.job_id,
199
+ progress_message: progressMessage,
200
+ confirmation_context: confirmationContext || {},
201
+ });
202
+ }
203
+ catch (error) {
204
+ this.pendingConfirmations.delete(job.job_id);
205
+ throw error;
206
+ }
207
+ return await confirmationPromise;
208
+ },
209
+ completed: async (result) => {
210
+ await sendJson({
211
+ type: "device.job.completed",
212
+ device_id: this.config.deviceId,
213
+ job_id: job.job_id,
214
+ result: result || {},
215
+ });
216
+ },
217
+ failed: async (errorMessage, result) => {
218
+ await sendJson({
219
+ type: "device.job.failed",
220
+ device_id: this.config.deviceId,
221
+ job_id: job.job_id,
222
+ error_message: errorMessage,
223
+ result: result || {},
224
+ });
225
+ },
226
+ });
227
+ }
228
+ catch (error) {
229
+ this.pendingConfirmations.delete(job.job_id);
230
+ const detail = error instanceof Error ? error.message : String(error);
231
+ try {
232
+ await sendJson({
233
+ type: "device.job.failed",
234
+ device_id: this.config.deviceId,
235
+ job_id: job.job_id,
236
+ error_message: detail || "Executor failed",
237
+ result: {
238
+ executor: this.config.executor.type,
239
+ },
240
+ });
241
+ }
242
+ catch {
243
+ // If the socket is already down, the reconnect path will recover but this job stays failed locally only.
244
+ }
245
+ throw error;
246
+ }
247
+ }
248
+ createDefaultExecutor(config) {
249
+ if (config.executor.type === "clawd-cursor") {
250
+ return new ClawdCursorJobExecutor(config.executor);
251
+ }
252
+ return new MockJobExecutor();
253
+ }
254
+ }
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ export const BRIDGE_CONFIG_VERSION = 1;
2
+ export const BRIDGE_VERSION = "0.1.0";
3
+ export const DEFAULT_API_BASE_URL = "http://localhost:8000";
4
+ export const DEFAULT_POLL_INTERVAL_MS = 3000;
5
+ export const DEFAULT_PAIR_TIMEOUT_SECONDS = 600;
6
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
7
+ export const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
8
+ export const DEFAULT_RECONNECT_MAX_DELAY_MS = 15000;
9
+ export const DEFAULT_EXECUTOR_TYPE = "mock";
10
+ export const DEFAULT_CLAWD_CURSOR_BASE_URL = "http://127.0.0.1:3847";
11
+ export const DEFAULT_CLAWD_CURSOR_POLL_INTERVAL_MS = 1500;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@leg3ndy/otto-bridge",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",
7
+ "keywords": [
8
+ "otto",
9
+ "bridge",
10
+ "desktop-automation",
11
+ "companion",
12
+ "agent"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/LGCYYL/ottoai.git",
17
+ "directory": "otto-bridge"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/LGCYYL/ottoai/issues"
21
+ },
22
+ "homepage": "https://github.com/LGCYYL/ottoai/tree/main/otto-bridge",
23
+ "license": "MIT",
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "package.json"
28
+ ],
29
+ "bin": {
30
+ "otto-bridge": "dist/main.js"
31
+ },
32
+ "scripts": {
33
+ "build": "node ./scripts/run-tsc.mjs -p tsconfig.json",
34
+ "typecheck": "node ./scripts/run-tsc.mjs -p tsconfig.json --noEmit",
35
+ "pack:dry-run": "npm pack --dry-run",
36
+ "release:check": "npm run typecheck && npm run build && npm run pack:dry-run",
37
+ "prepublishOnly": "npm run release:check",
38
+ "pair": "node dist/main.js pair",
39
+ "run": "node dist/main.js run",
40
+ "status": "node dist/main.js status",
41
+ "unpair": "node dist/main.js unpair"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=24"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^25.3.0",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }