@leg3ndy/otto-bridge 0.6.1 → 0.6.2

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 CHANGED
@@ -33,10 +33,10 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
33
33
 
34
34
  ```bash
35
35
  npm pack
36
- npm install -g ./leg3ndy-otto-bridge-0.6.1.tgz
36
+ npm install -g ./leg3ndy-otto-bridge-0.6.2.tgz
37
37
  ```
38
38
 
39
- No `0.6.1`, `playwright` deixa de ser opcional no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
39
+ No `0.6.2`, `playwright` deixa de ser opcional no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
40
40
 
41
41
  ## Publicacao
42
42
 
@@ -106,7 +106,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
106
106
 
107
107
  ### WhatsApp Web em background
108
108
 
109
- Fluxo recomendado no `0.6.1`:
109
+ Fluxo recomendado no `0.6.2`:
110
110
 
111
111
  ```bash
112
112
  otto-bridge extensions --install whatsappweb
@@ -116,6 +116,12 @@ otto-bridge extensions --status whatsappweb
116
116
 
117
117
  O setup agora abre o login do WhatsApp Web em um browser persistente do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
118
118
 
119
+ Contrato do `0.6.2`:
120
+
121
+ - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
122
+ - `otto-bridge run`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime estiver ativo
123
+ - ao parar o `otto-bridge run`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
124
+
119
125
  ### Ver estado local
120
126
 
121
127
  ```bash
@@ -970,9 +970,13 @@ export class NativeMacOSJobExecutor {
970
970
  lastVisualTargetDescription = null;
971
971
  lastVisualTargetApp = null;
972
972
  whatsappBackgroundBrowser = null;
973
+ whatsappRuntimeMonitor = null;
973
974
  constructor(bridgeConfig) {
974
975
  this.bridgeConfig = bridgeConfig;
975
976
  }
977
+ async start() {
978
+ await this.ensureManagedBackgroundServices();
979
+ }
976
980
  async run(job, reporter) {
977
981
  if (process.platform !== "darwin") {
978
982
  throw new Error("The native-macos executor only runs on macOS");
@@ -1481,7 +1485,6 @@ export class NativeMacOSJobExecutor {
1481
1485
  await reporter.completed(resultPayload);
1482
1486
  }
1483
1487
  finally {
1484
- await this.closeWhatsAppBackgroundBrowser();
1485
1488
  this.cancelledJobs.delete(job.job_id);
1486
1489
  }
1487
1490
  }
@@ -1492,6 +1495,14 @@ export class NativeMacOSJobExecutor {
1492
1495
  this.activeChild = null;
1493
1496
  }
1494
1497
  }
1498
+ async close() {
1499
+ if (this.whatsappRuntimeMonitor) {
1500
+ clearInterval(this.whatsappRuntimeMonitor);
1501
+ this.whatsappRuntimeMonitor = null;
1502
+ }
1503
+ await this.syncWhatsAppRuntimeDetached();
1504
+ await this.closeWhatsAppBackgroundBrowser();
1505
+ }
1495
1506
  assertNotCancelled(jobId) {
1496
1507
  if (this.cancelledJobs.has(jobId)) {
1497
1508
  throw new JobCancelledError(jobId);
@@ -2115,16 +2126,32 @@ end repeat
2115
2126
  activate,
2116
2127
  };
2117
2128
  }
2118
- async syncWhatsAppExtensionState(status, notes) {
2129
+ async syncWhatsAppExtensionState(status, notes, options) {
2119
2130
  const current = await loadManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG).catch(() => null);
2120
2131
  if (!current) {
2121
2132
  return;
2122
2133
  }
2134
+ const nowIso = new Date().toISOString();
2123
2135
  await saveManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG, {
2124
2136
  ...current,
2125
2137
  status,
2126
2138
  notes,
2127
- lastStatusCheckAt: new Date().toISOString(),
2139
+ runtimeAttached: options?.runtimeAttached ?? current.runtimeAttached,
2140
+ lastRuntimeHeartbeatAt: options?.runtimeAttached !== undefined
2141
+ ? nowIso
2142
+ : current.lastRuntimeHeartbeatAt,
2143
+ lastStatusCheckAt: nowIso,
2144
+ }).catch(() => undefined);
2145
+ }
2146
+ async syncWhatsAppRuntimeDetached() {
2147
+ const current = await loadManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG).catch(() => null);
2148
+ if (!current) {
2149
+ return;
2150
+ }
2151
+ await saveManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG, {
2152
+ ...current,
2153
+ runtimeAttached: false,
2154
+ lastRuntimeHeartbeatAt: new Date().toISOString(),
2128
2155
  }).catch(() => undefined);
2129
2156
  }
2130
2157
  async getWhatsAppBackgroundBrowser() {
@@ -2191,9 +2218,9 @@ return {
2191
2218
  const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
2192
2219
  if (backgroundBrowser) {
2193
2220
  try {
2194
- const state = await backgroundBrowser.getSessionState();
2221
+ const state = await backgroundBrowser.waitForStableSessionState();
2195
2222
  if (state.connected) {
2196
- await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso no browser em background.");
2223
+ await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso no browser em background.", { runtimeAttached: true });
2197
2224
  return;
2198
2225
  }
2199
2226
  const disconnectedStatus = currentState.status === "connected" || currentState.status === "session_expired"
@@ -2201,7 +2228,8 @@ return {
2201
2228
  : "waiting_login";
2202
2229
  await this.syncWhatsAppExtensionState(disconnectedStatus, state.qrVisible
2203
2230
  ? "QR code visivel no browser persistente. Escaneie com o celular para concluir o login."
2204
- : "Browser persistente do WhatsApp Web aberto, mas a sessao ainda nao esta pronta.");
2231
+ : "Browser persistente do WhatsApp Web aberto, mas a sessao ainda nao esta pronta.", { runtimeAttached: false });
2232
+ await this.closeWhatsAppBackgroundBrowser();
2205
2233
  if (disconnectedStatus === "session_expired") {
2206
2234
  throw new Error("A sessao do WhatsApp Web expirou nesta maquina. Rode `otto-bridge extensions --setup whatsappweb` para fazer login de novo e depois `otto-bridge extensions --status whatsappweb`.");
2207
2235
  }
@@ -2261,7 +2289,49 @@ return {
2261
2289
  }
2262
2290
  throw new Error("WhatsApp Web ainda nao esta conectado nesta maquina. Rode `otto-bridge extensions --setup whatsappweb`, escaneie o QR code e depois `otto-bridge extensions --status whatsappweb`.");
2263
2291
  }
2264
- await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso.");
2292
+ await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso.", { runtimeAttached: false });
2293
+ }
2294
+ async ensureManagedBackgroundServices() {
2295
+ if (!this.hasInstalledBridgeExtension(WHATSAPP_WEB_EXTENSION_SLUG)) {
2296
+ return;
2297
+ }
2298
+ if (!this.whatsappRuntimeMonitor) {
2299
+ this.whatsappRuntimeMonitor = setInterval(() => {
2300
+ void this.refreshWhatsAppBackgroundRuntime();
2301
+ }, 60_000);
2302
+ }
2303
+ await this.refreshWhatsAppBackgroundRuntime();
2304
+ }
2305
+ async refreshWhatsAppBackgroundRuntime() {
2306
+ const currentState = await loadManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG).catch(() => null);
2307
+ if (!currentState) {
2308
+ return;
2309
+ }
2310
+ if (currentState.status !== "connected") {
2311
+ await this.syncWhatsAppRuntimeDetached();
2312
+ await this.closeWhatsAppBackgroundBrowser();
2313
+ return;
2314
+ }
2315
+ const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
2316
+ if (!backgroundBrowser) {
2317
+ return;
2318
+ }
2319
+ try {
2320
+ const state = await backgroundBrowser.waitForStableSessionState({ timeoutMs: 12_000 });
2321
+ if (state.connected) {
2322
+ await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web mantida em background enquanto `otto-bridge run` estiver ativo.", { runtimeAttached: true });
2323
+ return;
2324
+ }
2325
+ await this.syncWhatsAppExtensionState("session_expired", state.qrVisible
2326
+ ? "A sessao do WhatsApp Web expirou durante o runtime. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code novamente."
2327
+ : "O browser persistente do WhatsApp Web nao confirmou uma sessao pronta durante o runtime.", { runtimeAttached: false });
2328
+ await this.closeWhatsAppBackgroundBrowser();
2329
+ }
2330
+ catch (error) {
2331
+ const detail = error instanceof Error ? error.message : String(error);
2332
+ await this.syncWhatsAppExtensionState("session_expired", detail || "Nao consegui manter a sessao do WhatsApp Web no runtime em background.", { runtimeAttached: false });
2333
+ await this.closeWhatsAppBackgroundBrowser();
2334
+ }
2265
2335
  }
2266
2336
  async selectWhatsAppConversation(contact) {
2267
2337
  const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
@@ -58,6 +58,8 @@ export async function buildInstalledManagedExtensionState(slug) {
58
58
  installedAt: existing?.installedAt || new Date().toISOString(),
59
59
  lastSetupAt: existing?.lastSetupAt,
60
60
  lastStatusCheckAt: existing?.lastStatusCheckAt,
61
+ runtimeAttached: existing?.runtimeAttached,
62
+ lastRuntimeHeartbeatAt: existing?.lastRuntimeHeartbeatAt,
61
63
  notes: existing?.notes,
62
64
  };
63
65
  }
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { pairDevice } from "./pairing.js";
7
7
  import { BridgeRuntime } from "./runtime.js";
8
8
  import { detectWhatsAppBackgroundStatus, runWhatsAppBackgroundSetup, } from "./whatsapp_background.js";
9
9
  import { BRIDGE_PACKAGE_NAME, BRIDGE_VERSION, DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
10
+ const RUNTIME_STATUS_FRESHNESS_MS = 90_000;
10
11
  function parseArgs(argv) {
11
12
  const [maybeCommand, ...rest] = argv;
12
13
  if (maybeCommand === "--help" || maybeCommand === "-h") {
@@ -46,6 +47,13 @@ function numberOption(args, name) {
46
47
  const parsed = Number(value);
47
48
  return Number.isFinite(parsed) ? parsed : undefined;
48
49
  }
50
+ function hasFreshRuntimeAttachment(state) {
51
+ if (!state?.runtimeAttached || !state.lastRuntimeHeartbeatAt) {
52
+ return false;
53
+ }
54
+ const lastHeartbeatAt = Date.parse(state.lastRuntimeHeartbeatAt);
55
+ return Number.isFinite(lastHeartbeatAt) && Date.now() - lastHeartbeatAt <= RUNTIME_STATUS_FRESHNESS_MS;
56
+ }
49
57
  function resolveExecutorOverrides(args, current) {
50
58
  return resolveExecutorConfig({
51
59
  type: option(args, "executor") || process.env.OTTO_BRIDGE_EXECUTOR,
@@ -112,6 +120,12 @@ async function detectManagedWhatsAppWebStatus() {
112
120
  }
113
121
  async function detectManagedExtensionStatus(slug, currentState) {
114
122
  if (slug === "whatsappweb") {
123
+ if (hasFreshRuntimeAttachment(currentState)) {
124
+ return {
125
+ status: "connected",
126
+ notes: currentState?.notes || "Sessao local do WhatsApp Web mantida em background enquanto `otto-bridge run` estiver ativo.",
127
+ };
128
+ }
115
129
  const detected = await detectManagedWhatsAppWebStatus();
116
130
  const shouldMarkExpired = (detected.status === "waiting_login"
117
131
  && (currentState?.status === "connected" || currentState?.status === "session_expired"));
@@ -161,7 +175,31 @@ async function runRuntimeCommand(args) {
161
175
  executor: resolveExecutorOverrides(args, config.executor),
162
176
  };
163
177
  const runtime = new BridgeRuntime(runtimeConfig);
164
- await runtime.start();
178
+ let stopping = false;
179
+ const shutdown = async (signal) => {
180
+ if (stopping) {
181
+ return;
182
+ }
183
+ stopping = true;
184
+ console.log(`[otto-bridge] shutting down runtime after ${signal}`);
185
+ await runtime.stop().catch(() => undefined);
186
+ };
187
+ const handleSigint = () => {
188
+ void shutdown("SIGINT");
189
+ };
190
+ const handleSigterm = () => {
191
+ void shutdown("SIGTERM");
192
+ };
193
+ process.once("SIGINT", handleSigint);
194
+ process.once("SIGTERM", handleSigterm);
195
+ try {
196
+ await runtime.start();
197
+ }
198
+ finally {
199
+ process.off("SIGINT", handleSigint);
200
+ process.off("SIGTERM", handleSigterm);
201
+ await runtime.stop().catch(() => undefined);
202
+ }
165
203
  }
166
204
  async function runStatusCommand() {
167
205
  const config = await loadBridgeConfig();
@@ -252,6 +290,7 @@ async function runExtensionsCommand(args) {
252
290
  };
253
291
  await saveManagedBridgeExtensionState(slug, {
254
292
  ...baseState,
293
+ runtimeAttached: false,
255
294
  notes: `Setup iniciado para ${currentState.displayName}. Aguarde o login no browser persistente e depois rode \`otto-bridge extensions --status ${slug}\`.`,
256
295
  });
257
296
  if (slug === "whatsappweb") {
@@ -263,6 +302,7 @@ async function runExtensionsCommand(args) {
263
302
  status: detected.status,
264
303
  notes: detected.notes,
265
304
  lastStatusCheckAt: new Date().toISOString(),
305
+ runtimeAttached: false,
266
306
  });
267
307
  console.log(`[otto-bridge] ${slug}: ${formatManagedBridgeExtensionStatus(detected.status)}`);
268
308
  if (detected.notes) {
@@ -293,6 +333,7 @@ async function runExtensionsCommand(args) {
293
333
  ...currentState,
294
334
  status: detected.status,
295
335
  lastStatusCheckAt: new Date().toISOString(),
336
+ runtimeAttached: hasFreshRuntimeAttachment(currentState) && detected.status === "connected",
296
337
  notes: detected.notes || currentState.notes,
297
338
  };
298
339
  await saveManagedBridgeExtensionState(slug, nextState);
package/dist/runtime.js CHANGED
@@ -73,6 +73,9 @@ export class BridgeRuntime {
73
73
  reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
74
74
  executor;
75
75
  lastBridgeReleaseNoticeKey = null;
76
+ activeSocket = null;
77
+ stopped = false;
78
+ started = false;
76
79
  pendingConfirmations = new Map();
77
80
  activeCancels = new Map();
78
81
  constructor(config, executor) {
@@ -136,23 +139,62 @@ export class BridgeRuntime {
136
139
  }));
137
140
  }
138
141
  async start() {
142
+ if (!this.started) {
143
+ this.started = true;
144
+ if (typeof this.executor.start === "function") {
145
+ await this.executor.start();
146
+ }
147
+ }
139
148
  console.log(`[otto-bridge] runtime start device=${this.config.deviceId}`);
140
- while (true) {
149
+ while (!this.stopped) {
141
150
  try {
142
151
  await this.connectOnce();
143
152
  this.reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
144
153
  }
145
154
  catch (error) {
155
+ if (this.stopped) {
156
+ break;
157
+ }
146
158
  const message = error instanceof Error ? error.message : String(error);
147
159
  console.error(`[otto-bridge] socket error: ${message}`);
148
160
  }
161
+ if (this.stopped) {
162
+ break;
163
+ }
149
164
  console.log(`[otto-bridge] reconnecting in ${this.reconnectDelayMs}ms`);
150
165
  await delay(this.reconnectDelayMs);
151
166
  this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, DEFAULT_RECONNECT_MAX_DELAY_MS);
152
167
  }
153
168
  }
169
+ async stop() {
170
+ this.stopped = true;
171
+ for (const [jobId, cancel] of this.activeCancels.entries()) {
172
+ try {
173
+ await cancel();
174
+ }
175
+ catch {
176
+ // ignore shutdown cancellation errors
177
+ }
178
+ finally {
179
+ this.activeCancels.delete(jobId);
180
+ }
181
+ }
182
+ try {
183
+ this.activeSocket?.close();
184
+ }
185
+ catch {
186
+ // ignore socket close errors during shutdown
187
+ }
188
+ finally {
189
+ this.activeSocket = null;
190
+ }
191
+ if (typeof this.executor.close === "function") {
192
+ await this.executor.close();
193
+ }
194
+ }
154
195
  async connectOnce() {
155
196
  const socket = new WebSocket(this.config.wsUrl, ["device", this.config.deviceToken]);
197
+ this.activeSocket = socket;
156
198
  let heartbeatTimer = null;
157
199
  const stopHeartbeat = () => {
158
200
  if (heartbeatTimer) {
@@ -196,12 +238,14 @@ export class BridgeRuntime {
196
238
  socket.addEventListener("close", (event) => {
197
239
  stopHeartbeat();
198
240
  rejectPendingConfirmations(new Error("WebSocket closed while awaiting confirmation"));
241
+ this.activeSocket = null;
199
242
  console.log(`[otto-bridge] socket closed code=${event.code}`);
200
243
  resolve();
201
244
  });
202
245
  socket.addEventListener("error", () => {
203
246
  stopHeartbeat();
204
247
  rejectPendingConfirmations(new Error("WebSocket failed while awaiting confirmation"));
248
+ this.activeSocket = null;
205
249
  try {
206
250
  socket.close();
207
251
  }
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.6.1";
2
+ export const BRIDGE_VERSION = "0.6.2";
3
3
  export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
4
4
  export const DEFAULT_API_BASE_URL = "http://localhost:8000";
5
5
  export const DEFAULT_POLL_INTERVAL_MS = 3000;
@@ -4,6 +4,9 @@ import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { getBridgeHomeDir } from "./config.js";
5
5
  export const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
6
6
  const DEFAULT_SETUP_TIMEOUT_MS = 5 * 60 * 1000;
7
+ const DEFAULT_SESSION_SETTLE_TIMEOUT_MS = 15_000;
8
+ const DEFAULT_SESSION_POLL_INTERVAL_MS = 1_000;
9
+ const DEFAULT_QR_STABILITY_WINDOW_MS = 4_000;
7
10
  function moduleDir() {
8
11
  return path.dirname(fileURLToPath(import.meta.url));
9
12
  }
@@ -158,7 +161,7 @@ export class WhatsAppBackgroundBrowser {
158
161
  };
159
162
  }
160
163
  async ensureReady() {
161
- const state = await this.getSessionState();
164
+ const state = await this.waitForStableSessionState();
162
165
  if (state.connected) {
163
166
  return state;
164
167
  }
@@ -179,6 +182,33 @@ export class WhatsAppBackgroundBrowser {
179
182
  }
180
183
  return lastState || await this.getSessionState();
181
184
  }
185
+ async waitForStableSessionState(options) {
186
+ const timeoutMs = Math.max(2_000, Number(options?.timeoutMs || DEFAULT_SESSION_SETTLE_TIMEOUT_MS));
187
+ const pollIntervalMs = Math.max(250, Number(options?.pollIntervalMs || DEFAULT_SESSION_POLL_INTERVAL_MS));
188
+ const qrStabilityWindowMs = Math.max(pollIntervalMs, Number(options?.qrStabilityWindowMs || DEFAULT_QR_STABILITY_WINDOW_MS));
189
+ const deadline = Date.now() + timeoutMs;
190
+ let lastState = null;
191
+ let qrVisibleSince = null;
192
+ while (Date.now() < deadline) {
193
+ lastState = await this.getSessionState();
194
+ if (lastState.connected) {
195
+ return lastState;
196
+ }
197
+ if (lastState.qrVisible) {
198
+ if (qrVisibleSince === null) {
199
+ qrVisibleSince = Date.now();
200
+ }
201
+ else if (Date.now() - qrVisibleSince >= qrStabilityWindowMs) {
202
+ return lastState;
203
+ }
204
+ }
205
+ else {
206
+ qrVisibleSince = null;
207
+ }
208
+ await this.page?.waitForTimeout(pollIntervalMs);
209
+ }
210
+ return lastState || await this.getSessionState();
211
+ }
182
212
  async selectConversation(contact) {
183
213
  await this.ensureReady();
184
214
  const prepared = await this.withPage((page) => page.evaluate((query) => {
@@ -464,7 +494,7 @@ export async function detectWhatsAppBackgroundStatus() {
464
494
  }
465
495
  const browser = new WhatsAppBackgroundBrowser({ headless: true });
466
496
  try {
467
- const state = await browser.getSessionState();
497
+ const state = await browser.waitForStableSessionState();
468
498
  return sessionStateToStatus(state);
469
499
  }
470
500
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",