@spikelabs/lobster-shell-plugin 0.2.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/dist/index.js ADDED
@@ -0,0 +1,905 @@
1
+ import {
2
+ OAuthFlow
3
+ } from "./chunk-RNTMVHEF.js";
4
+
5
+ // src/index.ts
6
+ import { hostname, platform } from "os";
7
+
8
+ // src/setup-page.ts
9
+ function renderSetupPage(opts) {
10
+ const statusColor = opts.connected ? "#22c55e" : "#ef4444";
11
+ const statusText = opts.connected ? "Connected" : "Disconnected";
12
+ return (
13
+ /* html */
14
+ `<!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="UTF-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ <title>Lobster Shell \u2014 Setup</title>
20
+ <style>
21
+ * { margin: 0; padding: 0; box-sizing: border-box; }
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
+ background: #0a0a0a;
25
+ color: #e5e5e5;
26
+ min-height: 100vh;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ }
31
+ .card {
32
+ background: #171717;
33
+ border: 1px solid #262626;
34
+ border-radius: 12px;
35
+ padding: 2rem;
36
+ max-width: 420px;
37
+ width: 100%;
38
+ }
39
+ h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
40
+ .status {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ margin: 1rem 0;
45
+ font-size: 0.9rem;
46
+ }
47
+ .dot {
48
+ width: 10px;
49
+ height: 10px;
50
+ border-radius: 50%;
51
+ background: ${statusColor};
52
+ }
53
+ .info {
54
+ background: #1a1a2e;
55
+ border-radius: 8px;
56
+ padding: 0.75rem 1rem;
57
+ font-size: 0.8rem;
58
+ color: #a1a1aa;
59
+ margin: 1rem 0;
60
+ word-break: break-all;
61
+ }
62
+ .info strong { color: #e5e5e5; }
63
+ .btn {
64
+ display: inline-block;
65
+ padding: 0.6rem 1.2rem;
66
+ border-radius: 8px;
67
+ border: none;
68
+ font-size: 0.9rem;
69
+ cursor: pointer;
70
+ text-decoration: none;
71
+ font-weight: 500;
72
+ }
73
+ .btn-primary {
74
+ background: #6366f1;
75
+ color: white;
76
+ }
77
+ .btn-primary:hover { background: #4f46e5; }
78
+ .btn-danger {
79
+ background: #dc2626;
80
+ color: white;
81
+ margin-left: 0.5rem;
82
+ }
83
+ .btn-danger:hover { background: #b91c1c; }
84
+ .message {
85
+ background: #1a2e1a;
86
+ border: 1px solid #22c55e33;
87
+ color: #86efac;
88
+ padding: 0.5rem 0.75rem;
89
+ border-radius: 6px;
90
+ font-size: 0.8rem;
91
+ margin-bottom: 1rem;
92
+ }
93
+ .error {
94
+ background: #2e1a1a;
95
+ border: 1px solid #ef444433;
96
+ color: #fca5a5;
97
+ padding: 0.5rem 0.75rem;
98
+ border-radius: 6px;
99
+ font-size: 0.8rem;
100
+ margin-bottom: 1rem;
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="card">
106
+ <h1>Lobster Shell</h1>
107
+ <p style="color: #a1a1aa; font-size: 0.85rem;">Connect your OpenClaw agent to Lobster Shell cloud.</p>
108
+
109
+ ${opts.message ? `<div class="message">${opts.message}</div>` : ""}
110
+ ${opts.error ? `<div class="error">${opts.error}</div>` : ""}
111
+
112
+ <div class="status">
113
+ <span class="dot"></span>
114
+ <span>${statusText}</span>
115
+ ${opts.bridgeStatus && opts.bridgeStatus !== statusText.toLowerCase() ? `<span style="color:#a1a1aa">(bridge: ${opts.bridgeStatus})</span>` : ""}
116
+ </div>
117
+
118
+ ${opts.connected ? `
119
+ <div class="info">
120
+ <div><strong>Gateway ID:</strong> ${opts.gatewayPublicId}</div>
121
+ <div><strong>Channel:</strong> ${opts.channelName}</div>
122
+ </div>
123
+ ` : ""}
124
+
125
+ <div style="margin-top: 1rem;">
126
+ ${opts.connected ? `<span style="color: #a1a1aa; font-size: 0.85rem;">Gateway is connected to Lobster Shell cloud.</span>` : `<a href="/lobster/connect" class="btn btn-primary">Connect to Lobster Shell</a>`}
127
+ </div>
128
+ </div>
129
+ </body>
130
+ </html>`
131
+ );
132
+ }
133
+
134
+ // src/cloud-bridge.ts
135
+ import { readFile, writeFile, mkdir } from "fs/promises";
136
+ import { join } from "path";
137
+
138
+ // src/bridge-protocol.ts
139
+ var ABLY_EVENT = {
140
+ PLUGIN: "plugin-event",
141
+ COMMAND: "cloud-command"
142
+ };
143
+
144
+ // src/cloud-bridge.ts
145
+ var CloudBridge = class {
146
+ cloudUrl;
147
+ stateDir;
148
+ logger;
149
+ _status = "disconnected";
150
+ ably = null;
151
+ channel = null;
152
+ heartbeatTimer = null;
153
+ commandHandlers = [];
154
+ constructor(opts) {
155
+ this.cloudUrl = opts.cloudUrl;
156
+ this.stateDir = opts.stateDir;
157
+ this.logger = opts.logger;
158
+ }
159
+ get status() {
160
+ return this._status;
161
+ }
162
+ onCommand(handler) {
163
+ this.commandHandlers.push(handler);
164
+ }
165
+ // -------------------------------------------------------------------------
166
+ // Connect
167
+ // -------------------------------------------------------------------------
168
+ async connect() {
169
+ const auth = await this.loadAuth();
170
+ if (!auth) {
171
+ this.logger.info("[lobster:bridge] No auth credentials \u2014 skipping connection");
172
+ return;
173
+ }
174
+ this._status = "connecting";
175
+ this.logger.info("[lobster:bridge] Connecting to Ably\u2026");
176
+ try {
177
+ const Ably = await import("ably");
178
+ this.ably = new Ably.Realtime({
179
+ authCallback: async (_params, callback) => {
180
+ try {
181
+ const tokenResponse = await this.fetchAblyToken(auth);
182
+ callback(null, tokenResponse.tokenRequest);
183
+ } catch (err) {
184
+ this.logger.error(`[lobster:bridge] Ably auth failed: ${err}`);
185
+ callback(err instanceof Error ? err.message : String(err), null);
186
+ }
187
+ },
188
+ autoConnect: true,
189
+ disconnectedRetryTimeout: 1e3,
190
+ suspendedRetryTimeout: 5e3
191
+ });
192
+ this.ably.connection.on("connected", () => {
193
+ this._status = "connected";
194
+ this.logger.info("[lobster:bridge] Connected to Ably");
195
+ this.startHeartbeat();
196
+ });
197
+ this.ably.connection.on("disconnected", () => {
198
+ this._status = "disconnected";
199
+ this.logger.warn("[lobster:bridge] Disconnected from Ably, will retry\u2026");
200
+ });
201
+ this.ably.connection.on("failed", () => {
202
+ this._status = "error";
203
+ this.logger.error("[lobster:bridge] Ably connection failed");
204
+ });
205
+ this.channel = this.ably.channels.get(auth.channelName);
206
+ this.channel.on("failed", (stateChange) => {
207
+ this._status = "error";
208
+ this.logger.error(
209
+ `[lobster:bridge] Channel failed: ${stateChange.reason?.message ?? "unknown"}`
210
+ );
211
+ });
212
+ try {
213
+ await this.channel.subscribe(ABLY_EVENT.COMMAND, (message) => {
214
+ const cmd = message.data;
215
+ this.logger.info(`[lobster:bridge] Received command: ${cmd.type}`);
216
+ for (const handler of this.commandHandlers) {
217
+ try {
218
+ handler(cmd);
219
+ } catch (err) {
220
+ this.logger.error(`[lobster:bridge] Command handler error: ${err}`);
221
+ }
222
+ }
223
+ });
224
+ } catch (err) {
225
+ this.logger.error(`[lobster:bridge] Channel subscribe failed: ${err}`);
226
+ this._status = "error";
227
+ return;
228
+ }
229
+ try {
230
+ await this.channel.presence.enter({ status: "online" });
231
+ } catch (err) {
232
+ this.logger.warn(`[lobster:bridge] Presence enter failed: ${err}`);
233
+ }
234
+ } catch (err) {
235
+ this._status = "error";
236
+ this.logger.error(`[lobster:bridge] Connection error: ${err}`);
237
+ }
238
+ }
239
+ // -------------------------------------------------------------------------
240
+ // Disconnect
241
+ // -------------------------------------------------------------------------
242
+ async disconnect() {
243
+ this.stopHeartbeat();
244
+ if (this.channel) {
245
+ try {
246
+ await this.channel.presence.leave();
247
+ } catch {
248
+ }
249
+ this.channel.unsubscribe();
250
+ this.channel = null;
251
+ }
252
+ if (this.ably) {
253
+ this.ably.close();
254
+ this.ably = null;
255
+ }
256
+ this._status = "disconnected";
257
+ this.logger.info("[lobster:bridge] Disconnected");
258
+ }
259
+ // -------------------------------------------------------------------------
260
+ // Publish (fire-and-forget)
261
+ // -------------------------------------------------------------------------
262
+ publish(event) {
263
+ if (!this.channel || this._status !== "connected") return;
264
+ this.channel.publish(ABLY_EVENT.PLUGIN, event).catch((err) => {
265
+ this.logger.warn(`[lobster:bridge] Publish failed: ${err}`);
266
+ });
267
+ }
268
+ // -------------------------------------------------------------------------
269
+ // Tenant JWT
270
+ // -------------------------------------------------------------------------
271
+ /**
272
+ * Get a valid tenant JWT, refreshing from cloud if expired or near-expiry.
273
+ * Returns null if not authenticated.
274
+ */
275
+ async getTenantToken() {
276
+ const auth = await this.loadAuth();
277
+ if (!auth?.tenantToken) return null;
278
+ const expiresAt = auth.tenantTokenExpiresAt ? new Date(auth.tenantTokenExpiresAt).getTime() : 0;
279
+ if (Date.now() > expiresAt - 5 * 60 * 1e3) {
280
+ const refreshed = await this.refreshTenantToken(auth);
281
+ return refreshed;
282
+ }
283
+ return auth.tenantToken;
284
+ }
285
+ async refreshTenantToken(auth) {
286
+ this.logger.info("[lobster:bridge] Refreshing tenant JWT\u2026");
287
+ try {
288
+ const res = await fetch(`${this.cloudUrl}/api/plugin/token`, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ Authorization: `Bearer ${auth.accessToken}`
293
+ }
294
+ });
295
+ if (!res.ok) {
296
+ this.logger.warn(`[lobster:bridge] Tenant token refresh failed: ${res.status}`);
297
+ return null;
298
+ }
299
+ const data = await res.json();
300
+ const updated = {
301
+ ...auth,
302
+ tenantToken: data.token,
303
+ tenantTokenExpiresAt: data.expiresAt
304
+ };
305
+ await mkdir(this.stateDir, { recursive: true });
306
+ await writeFile(
307
+ join(this.stateDir, "auth.json"),
308
+ JSON.stringify(updated, null, 2)
309
+ );
310
+ this.logger.info("[lobster:bridge] Tenant JWT refreshed");
311
+ return data.token;
312
+ } catch (err) {
313
+ this.logger.error(`[lobster:bridge] Tenant token refresh error: ${err}`);
314
+ return null;
315
+ }
316
+ }
317
+ // -------------------------------------------------------------------------
318
+ // Internals
319
+ // -------------------------------------------------------------------------
320
+ async loadAuth() {
321
+ try {
322
+ const raw = await readFile(join(this.stateDir, "auth.json"), "utf-8");
323
+ return JSON.parse(raw);
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+ async fetchAblyToken(auth) {
329
+ const res = await fetch(`${this.cloudUrl}/api/plugin/ably-token`, {
330
+ method: "POST",
331
+ headers: {
332
+ "Content-Type": "application/json",
333
+ Authorization: `Bearer ${auth.accessToken}`
334
+ },
335
+ body: JSON.stringify({ gatewayPublicId: auth.gatewayPublicId })
336
+ });
337
+ if (res.status === 401 && auth.refreshToken) {
338
+ this.logger.info("[lobster:bridge] Access token expired, refreshing\u2026");
339
+ const { OAuthFlow: OAuthFlow2 } = await import("./oauth-flow-O6AKA5CZ.js");
340
+ const oauthFlow2 = new OAuthFlow2({
341
+ stateDir: this.stateDir,
342
+ cloudUrl: this.cloudUrl,
343
+ logger: this.logger
344
+ });
345
+ const refreshed = await oauthFlow2.refreshAccessToken();
346
+ if (!refreshed) {
347
+ throw new Error("Access token expired and refresh failed \u2014 re-authenticate at /lobster/connect");
348
+ }
349
+ const retryRes = await fetch(`${this.cloudUrl}/api/plugin/ably-token`, {
350
+ method: "POST",
351
+ headers: {
352
+ "Content-Type": "application/json",
353
+ Authorization: `Bearer ${refreshed.accessToken}`
354
+ },
355
+ body: JSON.stringify({ gatewayPublicId: refreshed.gatewayPublicId })
356
+ });
357
+ if (!retryRes.ok) {
358
+ throw new Error(`Ably token request failed after refresh: ${retryRes.status}`);
359
+ }
360
+ return await retryRes.json();
361
+ }
362
+ if (!res.ok) {
363
+ throw new Error(`Ably token request failed: ${res.status}`);
364
+ }
365
+ return await res.json();
366
+ }
367
+ startHeartbeat() {
368
+ this.stopHeartbeat();
369
+ this.heartbeatTimer = setInterval(() => {
370
+ this.publish({ type: "heartbeat", timestamp: Date.now() });
371
+ }, 3e4);
372
+ }
373
+ stopHeartbeat() {
374
+ if (this.heartbeatTimer) {
375
+ clearInterval(this.heartbeatTimer);
376
+ this.heartbeatTimer = null;
377
+ }
378
+ }
379
+ };
380
+
381
+ // src/gateway-ws-client.ts
382
+ import { randomUUID } from "crypto";
383
+ var GatewayWsClient = class {
384
+ port;
385
+ token;
386
+ logger;
387
+ ws = null;
388
+ pending = /* @__PURE__ */ new Map();
389
+ _connected = false;
390
+ chatHandler = null;
391
+ constructor(opts) {
392
+ this.port = opts.port;
393
+ this.token = opts.token;
394
+ this.logger = opts.logger;
395
+ }
396
+ get connected() {
397
+ return this._connected;
398
+ }
399
+ onChat(handler) {
400
+ this.chatHandler = handler;
401
+ }
402
+ // -------------------------------------------------------------------------
403
+ // Connect
404
+ // -------------------------------------------------------------------------
405
+ async connect() {
406
+ const WebSocket = (await import("ws")).default;
407
+ return new Promise((resolve, reject) => {
408
+ const url = `ws://127.0.0.1:${this.port}`;
409
+ this.logger.info(`[lobster:ws] Connecting to gateway at ${url}`);
410
+ this.ws = new WebSocket(url, { handshakeTimeout: 8e3 });
411
+ const openTimeout = setTimeout(() => {
412
+ reject(new Error("WS open timeout"));
413
+ this.ws?.close();
414
+ }, 1e4);
415
+ this.ws.once("open", () => {
416
+ clearTimeout(openTimeout);
417
+ this.setupMessageHandler();
418
+ this.handshake().then(resolve).catch(reject);
419
+ });
420
+ this.ws.once("error", (err) => {
421
+ clearTimeout(openTimeout);
422
+ reject(err instanceof Error ? err : new Error(String(err)));
423
+ });
424
+ this.ws.on("close", () => {
425
+ this._connected = false;
426
+ this.logger.warn("[lobster:ws] Gateway WebSocket closed");
427
+ });
428
+ });
429
+ }
430
+ // -------------------------------------------------------------------------
431
+ // Send message to agent
432
+ // -------------------------------------------------------------------------
433
+ async sendMessage(text, sessionKey) {
434
+ if (!this._connected) {
435
+ this.logger.warn("[lobster:ws] Cannot send \u2014 not connected to gateway");
436
+ return;
437
+ }
438
+ this.logger.info(`[lobster:ws] \u2192 Sending to gateway: text="${text.slice(0, 100)}" sessionKey=${sessionKey ?? "main"}`);
439
+ const res = await this.request("chat.send", {
440
+ sessionKey: sessionKey ?? "main",
441
+ message: text,
442
+ idempotencyKey: randomUUID()
443
+ });
444
+ if (!res.ok) {
445
+ this.logger.error(`[lobster:ws] chat.send failed: ${JSON.stringify(res.error)}`);
446
+ }
447
+ }
448
+ // -------------------------------------------------------------------------
449
+ // Disconnect
450
+ // -------------------------------------------------------------------------
451
+ disconnect() {
452
+ for (const waiter of this.pending.values()) {
453
+ clearTimeout(waiter.timeout);
454
+ }
455
+ this.pending.clear();
456
+ if (this.ws) {
457
+ this.ws.close();
458
+ this.ws = null;
459
+ }
460
+ this._connected = false;
461
+ }
462
+ // -------------------------------------------------------------------------
463
+ // Internals
464
+ // -------------------------------------------------------------------------
465
+ async handshake() {
466
+ const res = await this.request("connect", {
467
+ minProtocol: 3,
468
+ maxProtocol: 3,
469
+ client: {
470
+ id: "cli",
471
+ displayName: "Lobster Shell Plugin",
472
+ version: "0.1.0",
473
+ platform: "node",
474
+ mode: "cli",
475
+ instanceId: "lobster-shell-bridge"
476
+ },
477
+ role: "operator",
478
+ scopes: ["operator.read", "operator.write"],
479
+ auth: { token: this.token }
480
+ });
481
+ if (!res.ok) {
482
+ throw new Error(`Gateway handshake failed: ${JSON.stringify(res.error)}`);
483
+ }
484
+ this._connected = true;
485
+ this.logger.info("[lobster:ws] Connected to gateway as operator");
486
+ }
487
+ request(method, params, timeoutMs = 15e3) {
488
+ return new Promise((resolve, reject) => {
489
+ if (!this.ws || this.ws.readyState !== 1) {
490
+ reject(new Error("WebSocket not open"));
491
+ return;
492
+ }
493
+ const id = randomUUID();
494
+ const frame = { type: "req", id, method, params };
495
+ const timeout = setTimeout(() => {
496
+ this.pending.delete(id);
497
+ reject(new Error(`Timeout waiting for ${method}`));
498
+ }, timeoutMs);
499
+ this.pending.set(id, { resolve, reject, timeout });
500
+ this.ws.send(JSON.stringify(frame));
501
+ });
502
+ }
503
+ setupMessageHandler() {
504
+ if (!this.ws) return;
505
+ this.ws.on("message", (data) => {
506
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
507
+ let frame;
508
+ try {
509
+ frame = JSON.parse(text);
510
+ } catch {
511
+ return;
512
+ }
513
+ if (frame.type === "res") {
514
+ const waiter = this.pending.get(frame.id);
515
+ if (waiter) {
516
+ this.pending.delete(frame.id);
517
+ clearTimeout(waiter.timeout);
518
+ waiter.resolve(frame);
519
+ }
520
+ } else if (frame.type === "event" && frame.event === "chat") {
521
+ const payload = frame.payload;
522
+ const contentPreview = payload.message?.content ? typeof payload.message.content === "string" ? payload.message.content.slice(0, 80) : JSON.stringify(payload.message.content).slice(0, 80) : "(none)";
523
+ this.logger.info(
524
+ `[lobster:ws] \u2190 Gateway chat event: state=${payload.state} role=${payload.message?.role ?? "?"} sessionKey=${payload.sessionKey} content="${contentPreview}"`
525
+ );
526
+ if (this.chatHandler) {
527
+ this.chatHandler(payload);
528
+ }
529
+ }
530
+ });
531
+ }
532
+ };
533
+
534
+ // src/api-client.ts
535
+ var bridge = null;
536
+ function initApiClient(cloudBridge) {
537
+ bridge = cloudBridge;
538
+ }
539
+
540
+ // src/index.ts
541
+ var PLUGIN_ID = "lobster-shell";
542
+ var CHANNEL_ID = "lobster-shell";
543
+ var PLUGIN_VERSION = "0.2.2";
544
+ var DEFAULT_CLOUD_URL = "https://www.lobstershell.ai";
545
+ var gatewayPort;
546
+ var bridge2 = null;
547
+ var oauthFlow = null;
548
+ var serviceCtx = null;
549
+ var gatewayWs = null;
550
+ function getCloudUrl(api) {
551
+ const cfg = api.pluginConfig ?? {};
552
+ return cfg["cloudUrl"] ?? DEFAULT_CLOUD_URL;
553
+ }
554
+ function buildGatewayInfo() {
555
+ return {
556
+ pluginVersion: PLUGIN_VERSION,
557
+ gatewayPort: gatewayPort ?? 18789,
558
+ os: `${platform()} ${process.arch}`,
559
+ hostname: hostname()
560
+ };
561
+ }
562
+ function parseQuery(req) {
563
+ const url = new URL(req.url ?? "/", "http://localhost");
564
+ const query = {};
565
+ for (const [key, value] of url.searchParams) {
566
+ query[key] = value;
567
+ }
568
+ return query;
569
+ }
570
+ function sendHtml(res, statusCode, html) {
571
+ res.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
572
+ res.end(html);
573
+ }
574
+ function sendRedirect(res, location) {
575
+ res.writeHead(302, { Location: location });
576
+ res.end();
577
+ }
578
+ function sendText(res, statusCode, text) {
579
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
580
+ res.end(text);
581
+ }
582
+ var plugin = {
583
+ id: PLUGIN_ID,
584
+ name: "Lobster Shell",
585
+ description: "Connect your OpenClaw agent to Lobster Shell cloud",
586
+ version: PLUGIN_VERSION,
587
+ register(api) {
588
+ const { logger } = api;
589
+ logger.info("Lobster Shell plugin loading\u2026");
590
+ const cloudUrl = getCloudUrl(api);
591
+ api.registerChannel({
592
+ id: CHANNEL_ID,
593
+ meta: {
594
+ id: CHANNEL_ID,
595
+ label: "Lobster Shell",
596
+ selectionLabel: "Lobster Shell",
597
+ docsPath: "/plugins/lobster-shell",
598
+ blurb: "AI avatar powered by Lobster Shell"
599
+ },
600
+ capabilities: {
601
+ chatTypes: ["direct"],
602
+ media: true
603
+ },
604
+ config: {
605
+ listAccountIds: () => ["default"],
606
+ resolveAccount: () => ({ accountId: "default", enabled: true })
607
+ },
608
+ outbound: {
609
+ deliveryMode: "direct",
610
+ async sendText(ctx) {
611
+ logger.info(
612
+ `[lobster:outbound] to=${ctx.to} text=${ctx.text.slice(0, 120)}\u2026`
613
+ );
614
+ bridge2?.publish({
615
+ type: "agent_response",
616
+ channelId: CHANNEL_ID,
617
+ to: ctx.to,
618
+ text: ctx.text
619
+ });
620
+ return { ok: true, messageId: `spike-${Date.now()}` };
621
+ },
622
+ async sendMedia(ctx) {
623
+ logger.info(
624
+ `[lobster:outbound] media to=${ctx.to} url=${ctx.mediaUrl ?? "none"}`
625
+ );
626
+ return { ok: true, messageId: `spike-media-${Date.now()}` };
627
+ }
628
+ }
629
+ });
630
+ api.on("message_sent", (event, ctx) => {
631
+ logger.info(
632
+ `[lobster:hook] message_sent ch=${ctx.channelId} to=${event.to} ok=${event.success}`
633
+ );
634
+ bridge2?.publish({
635
+ type: "agent_response",
636
+ channelId: ctx.channelId,
637
+ to: event.to,
638
+ text: event.content ?? "",
639
+ conversationId: ctx.conversationId
640
+ });
641
+ });
642
+ api.on(
643
+ "message_received",
644
+ (event, ctx) => {
645
+ logger.info(
646
+ `[lobster:hook] message_received ch=${ctx.channelId} from=${event.from}`
647
+ );
648
+ bridge2?.publish({
649
+ type: "message_received",
650
+ channelId: ctx.channelId,
651
+ from: event.from,
652
+ content: event.content,
653
+ conversationId: ctx.conversationId,
654
+ timestamp: event.timestamp
655
+ });
656
+ }
657
+ );
658
+ api.on("gateway_start", (event, _ctx) => {
659
+ gatewayPort = event.port;
660
+ logger.info(`[lobster:hook] gateway started on port ${gatewayPort}`);
661
+ bridge2?.publish({ type: "gateway_start", port: gatewayPort });
662
+ });
663
+ api.registerHttpRoute({
664
+ path: "/lobster/setup",
665
+ auth: "plugin",
666
+ async handler(req, res) {
667
+ if (req.method !== "GET") {
668
+ sendText(res, 405, "Method Not Allowed");
669
+ return;
670
+ }
671
+ const query = parseQuery(req);
672
+ const flow = oauthFlow;
673
+ const auth = flow ? await flow.loadAuth() : null;
674
+ sendHtml(
675
+ res,
676
+ 200,
677
+ renderSetupPage({
678
+ connected: !!auth,
679
+ gatewayPublicId: auth?.gatewayPublicId,
680
+ channelName: auth?.channelName,
681
+ bridgeStatus: bridge2?.status ?? "not started",
682
+ message: query["success"] ? "Connected to Lobster Shell!" : void 0
683
+ })
684
+ );
685
+ }
686
+ });
687
+ api.registerHttpRoute({
688
+ path: "/lobster/connect",
689
+ auth: "plugin",
690
+ async handler(req, res) {
691
+ if (req.method !== "GET") {
692
+ sendText(res, 405, "Method Not Allowed");
693
+ return;
694
+ }
695
+ if (!oauthFlow) {
696
+ sendText(res, 500, "Plugin not initialized yet");
697
+ return;
698
+ }
699
+ const host = req.headers.host ?? `localhost:${gatewayPort ?? 18789}`;
700
+ const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
701
+ const redirectUri = `${proto}://${host}/lobster/oauth/callback`;
702
+ try {
703
+ const authUrl = await oauthFlow.initiateFlow(redirectUri);
704
+ sendRedirect(res, authUrl);
705
+ } catch (err) {
706
+ logger.error(`[lobster:oauth] Failed to initiate flow: ${err}`);
707
+ sendHtml(
708
+ res,
709
+ 200,
710
+ renderSetupPage({
711
+ connected: false,
712
+ error: `OAuth initiation failed: ${err instanceof Error ? err.message : String(err)}`
713
+ })
714
+ );
715
+ }
716
+ }
717
+ });
718
+ api.registerHttpRoute({
719
+ path: "/lobster/oauth/callback",
720
+ auth: "plugin",
721
+ async handler(req, res) {
722
+ if (req.method !== "GET") {
723
+ sendText(res, 405, "Method Not Allowed");
724
+ return;
725
+ }
726
+ if (!oauthFlow) {
727
+ sendText(res, 500, "Plugin not initialized yet");
728
+ return;
729
+ }
730
+ const query = parseQuery(req);
731
+ try {
732
+ await oauthFlow.handleCallback(query, buildGatewayInfo());
733
+ if (bridge2 && serviceCtx) {
734
+ await bridge2.disconnect();
735
+ await bridge2.connect();
736
+ }
737
+ sendRedirect(res, "/lobster/setup?success=1");
738
+ } catch (err) {
739
+ logger.error(`[lobster:oauth] Callback failed: ${err}`);
740
+ sendHtml(
741
+ res,
742
+ 200,
743
+ renderSetupPage({
744
+ connected: false,
745
+ error: `OAuth callback failed: ${err instanceof Error ? err.message : String(err)}`
746
+ })
747
+ );
748
+ }
749
+ }
750
+ });
751
+ api.registerService({
752
+ id: `${PLUGIN_ID}-bridge`,
753
+ async start(ctx) {
754
+ serviceCtx = ctx;
755
+ ctx.logger.info("[lobster:bridge] service starting\u2026");
756
+ ctx.logger.info(`[lobster:bridge] state dir: ${ctx.stateDir}`);
757
+ oauthFlow = new OAuthFlow({
758
+ stateDir: ctx.stateDir,
759
+ cloudUrl,
760
+ logger: ctx.logger
761
+ });
762
+ bridge2 = new CloudBridge({
763
+ cloudUrl,
764
+ stateDir: ctx.stateDir,
765
+ logger: ctx.logger
766
+ });
767
+ bridge2.onCommand((cmd) => {
768
+ if (cmd.type === "ping") {
769
+ ctx.logger.info("[lobster:bridge] Received ping, publishing heartbeat");
770
+ bridge2?.publish({ type: "heartbeat", timestamp: Date.now() });
771
+ } else if (cmd.type === "send_message") {
772
+ ctx.logger.info(`[lobster:bridge] \u2190 Received send_message from cloud: text="${cmd.text.slice(0, 100)}" sessionKey=${cmd.sessionKey}`);
773
+ bridge2?.publish({
774
+ type: "message_received",
775
+ channelId: "cloud",
776
+ from: "cloud",
777
+ content: cmd.text,
778
+ sessionKey: cmd.sessionKey
779
+ });
780
+ if (cmd.sessionKey) {
781
+ const gatewayKey = `agent:main:${cmd.sessionKey}`;
782
+ sessionKeyMap.set(gatewayKey, cmd.sessionKey);
783
+ ctx.logger.info(`[lobster:bridge] sessionKey mapping: ${gatewayKey} \u2192 ${cmd.sessionKey}`);
784
+ }
785
+ gatewayWs?.sendMessage(cmd.text, cmd.sessionKey);
786
+ } else if (cmd.type === "avatar_config") {
787
+ ctx.logger.info(`[lobster:bridge] Avatar config received: ${cmd.avatarId}`);
788
+ } else if (cmd.type === "update_settings") {
789
+ ctx.logger.info("[lobster:bridge] Settings update received");
790
+ }
791
+ });
792
+ await bridge2.connect();
793
+ const port = gatewayPort ?? 18789;
794
+ const token = process.env["OPENCLAW_GATEWAY_TOKEN"] ?? "dev-token-1234";
795
+ gatewayWs = new GatewayWsClient({ port, token, logger: ctx.logger });
796
+ const thinkingPublished = /* @__PURE__ */ new Set();
797
+ const sessionContentLen = /* @__PURE__ */ new Map();
798
+ const sessionKeyMap = /* @__PURE__ */ new Map();
799
+ gatewayWs.onChat((payload) => {
800
+ const gatewaySessionKey = payload.sessionKey ?? "__default__";
801
+ const cloudSessionKey = sessionKeyMap.get(gatewaySessionKey) ?? gatewaySessionKey;
802
+ if (payload.state === "final" && payload.message?.role === "assistant") {
803
+ thinkingPublished.delete(gatewaySessionKey);
804
+ sessionContentLen.delete(gatewaySessionKey);
805
+ sessionKeyMap.delete(gatewaySessionKey);
806
+ const raw = payload.message.content;
807
+ let text;
808
+ if (typeof raw === "string") {
809
+ text = raw;
810
+ } else if (Array.isArray(raw)) {
811
+ text = raw.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
812
+ } else {
813
+ text = String(raw ?? "");
814
+ }
815
+ ctx.logger.info(`[lobster:ws] \u2713 Agent final response (${text.length} chars, sessionKey=${cloudSessionKey}): "${text.slice(0, 120)}"`);
816
+ bridge2?.publish({
817
+ type: "agent_response",
818
+ channelId: "webchat",
819
+ to: "cloud",
820
+ text,
821
+ runId: payload.runId,
822
+ sessionKey: cloudSessionKey
823
+ });
824
+ } else if (payload.state !== "final" && payload.message?.role === "assistant") {
825
+ if (!thinkingPublished.has(gatewaySessionKey)) {
826
+ thinkingPublished.add(gatewaySessionKey);
827
+ ctx.logger.info(`[lobster:ws] \u25C6 Agent thinking (gatewayKey=${gatewaySessionKey}, cloudKey=${cloudSessionKey})`);
828
+ bridge2?.publish({
829
+ type: "agent_thinking",
830
+ runId: payload.runId,
831
+ sessionKey: cloudSessionKey
832
+ });
833
+ }
834
+ const rawDelta = payload.message.content;
835
+ let fullText;
836
+ if (typeof rawDelta === "string" && rawDelta.length > 0) {
837
+ fullText = rawDelta;
838
+ } else if (Array.isArray(rawDelta)) {
839
+ const joined = rawDelta.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
840
+ if (joined.length > 0) fullText = joined;
841
+ }
842
+ if (fullText) {
843
+ const prevLen = sessionContentLen.get(gatewaySessionKey) ?? 0;
844
+ const deltaText = fullText.slice(prevLen);
845
+ sessionContentLen.set(gatewaySessionKey, fullText.length);
846
+ if (deltaText.length > 0) {
847
+ ctx.logger.info(`[lobster:ws] \u25C6 Delta (${deltaText.length} new chars, cumulative=${fullText.length}, sessionKey=${cloudSessionKey}): "${deltaText.slice(0, 60)}"`);
848
+ bridge2?.publish({
849
+ type: "agent_response_delta",
850
+ text: deltaText,
851
+ runId: payload.runId,
852
+ sessionKey: cloudSessionKey
853
+ });
854
+ }
855
+ }
856
+ }
857
+ });
858
+ try {
859
+ await gatewayWs.connect();
860
+ } catch (err) {
861
+ ctx.logger.warn(`[lobster:ws] Gateway WS connection failed: ${err}`);
862
+ }
863
+ initApiClient(bridge2);
864
+ },
865
+ async stop(ctx) {
866
+ ctx.logger.info("[lobster:bridge] service stopping\u2026");
867
+ gatewayWs?.disconnect();
868
+ gatewayWs = null;
869
+ bridge2?.publish({ type: "gateway_stop" });
870
+ await bridge2?.disconnect();
871
+ bridge2 = null;
872
+ oauthFlow = null;
873
+ serviceCtx = null;
874
+ }
875
+ });
876
+ api.registerCommand({
877
+ name: "lobster-status",
878
+ description: "Show Lobster Shell plugin status",
879
+ requireAuth: true,
880
+ handler(_ctx) {
881
+ const status = {
882
+ plugin: PLUGIN_ID,
883
+ version: PLUGIN_VERSION,
884
+ gatewayPort: gatewayPort ?? "unknown",
885
+ cloudBridge: bridge2?.status ?? "not started",
886
+ channel: CHANNEL_ID
887
+ };
888
+ return {
889
+ text: [
890
+ "**Lobster Shell Status**",
891
+ `Plugin: ${status.plugin} v${status.version}`,
892
+ `Gateway: port ${status.gatewayPort}`,
893
+ `Cloud Bridge: ${status.cloudBridge}`,
894
+ `Channel: ${status.channel}`
895
+ ].join("\n")
896
+ };
897
+ }
898
+ });
899
+ logger.info("Lobster Shell plugin registered successfully");
900
+ }
901
+ };
902
+ var index_default = plugin;
903
+ export {
904
+ index_default as default
905
+ };