@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 +117 -0
- package/dist/config.js +131 -0
- package/dist/executors/base.js +1 -0
- package/dist/executors/clawd_cursor.js +236 -0
- package/dist/executors/mock.js +33 -0
- package/dist/http.js +32 -0
- package/dist/main.js +137 -0
- package/dist/pairing.js +56 -0
- package/dist/runtime.js +254 -0
- package/dist/types.js +11 -0
- package/package.json +53 -0
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
|
+
});
|
package/dist/pairing.js
ADDED
|
@@ -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
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -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
|
+
}
|