botschat 0.1.20 → 0.1.22

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.
Files changed (53) hide show
  1. package/README.md +68 -1
  2. package/package.json +1 -1
  3. package/packages/api/src/do/connection-do.ts +186 -382
  4. package/packages/api/src/index.ts +50 -67
  5. package/packages/api/src/routes/agents.ts +3 -3
  6. package/packages/api/src/routes/auth.ts +1 -0
  7. package/packages/api/src/routes/channels.ts +11 -11
  8. package/packages/api/src/routes/demo.ts +156 -0
  9. package/packages/api/src/routes/sessions.ts +5 -5
  10. package/packages/api/src/routes/tasks.ts +33 -33
  11. package/packages/plugin/dist/src/channel.js +50 -0
  12. package/packages/plugin/dist/src/channel.js.map +1 -1
  13. package/packages/plugin/package.json +23 -4
  14. package/packages/web/dist/assets/index-BaRi2ZPe.js +1517 -0
  15. package/packages/web/dist/assets/index-Be-wHtaM.js +1 -0
  16. package/packages/web/dist/assets/index-BtPyCBCl.css +1 -0
  17. package/packages/web/dist/assets/index-C8XPcaR1.js +2 -0
  18. package/packages/web/dist/assets/index-CyXci1aU.js +2 -0
  19. package/packages/web/dist/assets/{index-DYCO-ry1.js → index-DVvunB1I.js} +1 -1
  20. package/packages/web/dist/assets/{index-CYQMu_-c.js → index-SvEo7uGi.js} +1 -1
  21. package/packages/web/dist/assets/{index.esm-CvOpngZM.js → index.esm-DAPLRyFT.js} +1 -1
  22. package/packages/web/dist/assets/{web-D3LMODYp.js → web-C6s08DDW.js} +1 -1
  23. package/packages/web/dist/assets/{web-1cdhq2RW.js → web-K88lR3JA.js} +1 -1
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/src/App.tsx +44 -57
  26. package/packages/web/src/api.ts +5 -61
  27. package/packages/web/src/components/ChatWindow.tsx +9 -9
  28. package/packages/web/src/components/CronDetail.tsx +1 -1
  29. package/packages/web/src/components/ImageLightbox.tsx +96 -0
  30. package/packages/web/src/components/LoginPage.tsx +78 -1
  31. package/packages/web/src/components/MessageContent.tsx +17 -2
  32. package/packages/web/src/components/SessionTabs.tsx +1 -1
  33. package/packages/web/src/components/Sidebar.tsx +1 -3
  34. package/packages/web/src/hooks/useIMEComposition.ts +14 -9
  35. package/packages/web/src/store.ts +7 -39
  36. package/packages/web/src/ws.ts +0 -1
  37. package/scripts/dev.sh +0 -53
  38. package/migrations/0013_agents_table.sql +0 -29
  39. package/migrations/0014_agent_sessions.sql +0 -19
  40. package/migrations/0015_message_traces.sql +0 -27
  41. package/migrations/0016_multi_agent_channels_messages.sql +0 -9
  42. package/migrations/0017_rename_cron_job_id.sql +0 -2
  43. package/packages/api/src/protocol-v2.ts +0 -154
  44. package/packages/api/src/routes/agents-v2.ts +0 -192
  45. package/packages/api/src/routes/history-v2.ts +0 -221
  46. package/packages/api/src/routes/migrate-v2.ts +0 -110
  47. package/packages/web/dist/assets/index-BARPtt0v.css +0 -1
  48. package/packages/web/dist/assets/index-Bf-XL3te.js +0 -2
  49. package/packages/web/dist/assets/index-CYlvfpX9.js +0 -1519
  50. package/packages/web/dist/assets/index-CxcpA4Qo.js +0 -1
  51. package/packages/web/dist/assets/index-QebPVqwj.js +0 -2
  52. package/packages/web/src/components/AgentSettings.tsx +0 -328
  53. package/scripts/mock-openclaw-v2.mjs +0 -486
@@ -4,6 +4,7 @@ import { getFcmAccessToken, sendPushNotification } from "../utils/fcm.js";
4
4
  import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
5
5
  import { generateId as generateIdUtil } from "../utils/id.js";
6
6
  import { randomUUID } from "../utils/uuid.js";
7
+ import { isDemoUserId } from "../routes/demo.js";
7
8
 
8
9
  /** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
9
10
  interface BrowserAttachment {
@@ -29,8 +30,7 @@ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
29
30
  * - Use WebSocket Hibernation API so idle users cost zero compute
30
31
  *
31
32
  * Connection tagging (via serializeAttachment / deserializeAttachment):
32
- * - "agent:<agentId>" = WebSocket from an agent plugin (v2, per-agent)
33
- * - "openclaw" = legacy WebSocket from the OpenClaw plugin (backward compat)
33
+ * - "openclaw" = the WebSocket from the OpenClaw plugin
34
34
  * - "browser:<sessionId>" = a browser client WebSocket
35
35
  */
36
36
  export class ConnectionDO implements DurableObject {
@@ -54,9 +54,6 @@ export class ConnectionDO implements DurableObject {
54
54
  /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
55
55
  private lastOpenClawAcceptedAt = 0;
56
56
 
57
- /** Pending agent.request delegations — maps requestId to originator info. */
58
- private pendingRequests = new Map<string, { fromAgentId: string; sessionKey: string; depth: number; createdAt: number }>();
59
-
60
57
  constructor(state: DurableObjectState, env: Env) {
61
58
  this.state = state;
62
59
  this.env = env;
@@ -92,8 +89,7 @@ export class ConnectionDO implements DurableObject {
92
89
  }
93
90
  }
94
91
  const preVerified = url.searchParams.get("verified") === "1";
95
- const agentId = url.searchParams.get("agentId");
96
- return this.handleOpenClawConnect(request, preVerified, agentId);
92
+ return this.handleOpenClawConnect(request, preVerified);
97
93
  }
98
94
 
99
95
  // Route: /client/:sessionId — Browser client connects here
@@ -109,8 +105,9 @@ export class ConnectionDO implements DurableObject {
109
105
  // Route: /models — Available models (REST)
110
106
  if (url.pathname === "/models") {
111
107
  await this.ensureCachedModels();
112
- console.log(`[DO] GET /models returning ${this.cachedModels.length} models`);
113
- return Response.json({ models: this.cachedModels });
108
+ const models = this.cachedModels.length ? this.cachedModels : (await this.isDemoUser() ? ConnectionDO.DEMO_MODELS : []);
109
+ console.log(`[DO] GET /models — returning ${models.length} models`);
110
+ return Response.json({ models });
114
111
  }
115
112
 
116
113
  // Route: /scan-data — Cached OpenClaw scan data (schedule/instructions/model)
@@ -146,7 +143,7 @@ export class ConnectionDO implements DurableObject {
146
143
  return;
147
144
  }
148
145
 
149
- if (tag === "openclaw" || tag?.startsWith("agent:")) {
146
+ if (tag === "openclaw") {
150
147
  await this.handleOpenClawMessage(ws, parsed);
151
148
  } else if (tag?.startsWith("browser:")) {
152
149
  await this.handleBrowserMessage(ws, parsed);
@@ -167,7 +164,8 @@ export class ConnectionDO implements DurableObject {
167
164
  /** Called when a WebSocket is closed. */
168
165
  async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise<void> {
169
166
  const tag = this.getTag(ws);
170
- if (tag === "openclaw" || tag?.startsWith("agent:")) {
167
+ if (tag === "openclaw") {
168
+ // OpenClaw disconnected — notify all browser clients
171
169
  this.broadcastToBrowsers(
172
170
  JSON.stringify({ type: "openclaw.disconnected" }),
173
171
  );
@@ -190,7 +188,7 @@ export class ConnectionDO implements DurableObject {
190
188
 
191
189
  // ---- Connection handlers ----
192
190
 
193
- private handleOpenClawConnect(request: Request, preVerified = false, agentId?: string | null): Response {
191
+ private handleOpenClawConnect(request: Request, preVerified = false): Response {
194
192
  if (request.headers.get("Upgrade") !== "websocket") {
195
193
  return new Response("Expected WebSocket upgrade", { status: 426 });
196
194
  }
@@ -206,14 +204,12 @@ export class ConnectionDO implements DurableObject {
206
204
  }
207
205
  this.lastOpenClawAcceptedAt = now;
208
206
 
209
- const wsTag = agentId ? "agent:" + agentId : "openclaw";
210
-
211
- // Safety valve: if stale sockets with the same tag accumulated (e.g. from
207
+ // Safety valve: if stale openclaw sockets accumulated (e.g. from
212
208
  // rapid reconnects that authenticated but then lost their edge
213
209
  // connection), close them all before accepting a new one.
214
- const existing = this.state.getWebSockets(wsTag);
210
+ const existing = this.state.getWebSockets("openclaw");
215
211
  if (existing.length > 3) {
216
- console.warn(`[DO] Safety valve: ${existing.length} ${wsTag} sockets, closing all`);
212
+ console.warn(`[DO] Safety valve: ${existing.length} openclaw sockets, closing all`);
217
213
  for (const s of existing) {
218
214
  try { s.close(4009, "replaced"); } catch { /* dead */ }
219
215
  }
@@ -222,12 +218,12 @@ export class ConnectionDO implements DurableObject {
222
218
  const pair = new WebSocketPair();
223
219
  const [client, server] = [pair[0], pair[1]];
224
220
 
225
- this.state.acceptWebSocket(server, [wsTag]);
221
+ this.state.acceptWebSocket(server, ["openclaw"]);
226
222
 
227
223
  // If the API worker already verified the token against D1, mark as
228
224
  // pre-verified. The plugin still sends an auth message, which we'll
229
225
  // fast-track through without re-validating the token.
230
- server.serializeAttachment({ authenticated: false, tag: wsTag, agentId: agentId ?? null, preVerified });
226
+ server.serializeAttachment({ authenticated: false, tag: "openclaw", preVerified });
231
227
 
232
228
  return new Response(null, { status: 101, webSocket: client });
233
229
  }
@@ -276,12 +272,11 @@ export class ConnectionDO implements DurableObject {
276
272
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
277
273
 
278
274
  if (isValid) {
279
- // Close other sockets with the SAME tag. Use custom code 4009 so
275
+ // Close ALL other openclaw sockets. Use custom code 4009 so
280
276
  // well-behaved plugins know they were replaced (not a crash)
281
277
  // and should NOT reconnect. The Worker-level rate limit (10s)
282
278
  // prevents the resulting close event from flooding the DO.
283
- const wsTag = attachment?.tag ?? "openclaw";
284
- const existingSockets = this.state.getWebSockets(wsTag);
279
+ const existingSockets = this.state.getWebSockets("openclaw");
285
280
  let closedCount = 0;
286
281
  for (const oldWs of existingSockets) {
287
282
  if (oldWs !== ws) {
@@ -323,15 +318,6 @@ export class ConnectionDO implements DurableObject {
323
318
  return;
324
319
  }
325
320
 
326
- // Resolve @channelName session keys to real adhoc task session keys
327
- if (
328
- (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui" ||
329
- msg.type === "agent.stream.start" || msg.type === "agent.stream.chunk" || msg.type === "agent.stream.end") &&
330
- typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("@")
331
- ) {
332
- msg.sessionKey = await this.resolveChannelSessionKey(msg.sessionKey);
333
- }
334
-
335
321
  // Persist agent messages to D1 (skip transient stream events)
336
322
  if (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") {
337
323
  console.log("[DO] Agent outbound:", JSON.stringify({
@@ -357,9 +343,6 @@ export class ConnectionDO implements DurableObject {
357
343
  // Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
358
344
  const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
359
345
 
360
- // Extract agentId from the sending WebSocket's attachment
361
- const wsAtt = ws.deserializeAttachment() as { agentId?: string } | null;
362
-
363
346
  await this.persistMessage({
364
347
  id: msg.messageId as string | undefined,
365
348
  sender: "agent",
@@ -369,7 +352,6 @@ export class ConnectionDO implements DurableObject {
369
352
  mediaUrl: persistedMediaUrl,
370
353
  a2ui: msg.jsonl as string | undefined,
371
354
  encrypted: encryptedBits,
372
- senderAgentId: (msg.agentId as string | undefined) ?? wsAtt?.agentId ?? undefined,
373
355
  });
374
356
  }
375
357
 
@@ -383,7 +365,7 @@ export class ConnectionDO implements DurableObject {
383
365
  if (msg.type === "task.schedule.ack" && msg.ok && msg.taskId && msg.cronJobId) {
384
366
  try {
385
367
  await this.env.DB.prepare(
386
- "UPDATE tasks SET provider_job_id = ? WHERE id = ?",
368
+ "UPDATE tasks SET openclaw_cron_job_id = ? WHERE id = ?",
387
369
  ).bind(msg.cronJobId, msg.taskId).run();
388
370
  console.log(`[DO] Updated task ${msg.taskId} with cronJobId ${msg.cronJobId}`);
389
371
  } catch (err) {
@@ -424,44 +406,11 @@ export class ConnectionDO implements DurableObject {
424
406
  await this.handleJobUpdate(msg);
425
407
  }
426
408
 
427
- // Handle agent.request — Agent-to-Agent delegation
428
- if (msg.type === "agent.request") {
429
- await this.handleAgentRequest(ws, msg);
430
- return;
431
- }
432
-
433
- // Handle agent.trace — persist verbose execution traces (lv2/lv3)
434
- if (msg.type === "agent.trace") {
435
- await this.handleAgentTrace(msg);
436
- return;
437
- }
438
-
439
409
  // Forward all messages to browser clients (strip notifyPreview — plaintext
440
410
  // must not be relayed to browser WebSockets; browsers decrypt locally)
441
411
  if (msg.type === "agent.text") {
442
412
  console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
443
413
  }
444
-
445
- const wsAttForReply = ws.deserializeAttachment() as { agentId?: string } | null;
446
-
447
- // Check if this agent.text is a response to a pending agent.request
448
- if ((msg.type === "agent.text" || msg.type === "agent.stream.end") && msg.requestId) {
449
- const pending = this.pendingRequests.get(msg.requestId as string);
450
- if (pending) {
451
- const originSocket = this.getAgentSocket(pending.fromAgentId);
452
- if (originSocket) {
453
- originSocket.send(JSON.stringify({
454
- type: "agent.response",
455
- requestId: msg.requestId,
456
- fromAgentId: (msg.agentId as string) ?? wsAttForReply?.agentId ?? null,
457
- text: (msg.text ?? "") as string,
458
- sessionKey: (msg.sessionKey ?? "") as string,
459
- }));
460
- }
461
- this.pendingRequests.delete(msg.requestId as string);
462
- }
463
- }
464
-
465
414
  const { notifyPreview: _stripped, ...msgForBrowser } = msg;
466
415
  this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
467
416
 
@@ -509,40 +458,27 @@ export class ConnectionDO implements DurableObject {
509
458
  }
510
459
 
511
460
  ws.serializeAttachment({ ...attachment, authenticated: true });
461
+ // Include userId so the browser can derive the E2E key
512
462
  const doUserId2 = doUserId ?? payload.sub;
513
-
514
- // Fetch available agents from D1 for the auth.ok response
515
- let availableAgents: Array<{ id: string; name: string; type: string; role: string; capabilities: string[]; status: string }> = [];
516
- try {
517
- const { results } = await this.env.DB.prepare(
518
- "SELECT id, name, type, role, capabilities, status FROM agents WHERE user_id = ? ORDER BY created_at ASC",
519
- )
520
- .bind(doUserId2)
521
- .all<{ id: string; name: string; type: string; role: string; capabilities: string; status: string }>();
522
- availableAgents = (results ?? []).map((r) => ({
523
- id: r.id,
524
- name: r.name,
525
- type: r.type,
526
- role: r.role,
527
- capabilities: JSON.parse(r.capabilities || "[]"),
528
- status: r.status,
529
- }));
530
- } catch (err) {
531
- console.error("[DO] Failed to fetch agents for auth.ok:", err);
463
+ // Persist userId to storage so isDemoUser() and other helpers work
464
+ // even when no OpenClaw plugin has connected (e.g. demo mode).
465
+ if (!doUserId) {
466
+ await this.state.storage.put("userId", doUserId2);
532
467
  }
468
+ ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2 }));
533
469
 
534
- ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2, availableAgents }));
535
-
536
- // Send current connection status + cached models
470
+ // Send current OpenClaw connection status + cached models
537
471
  await this.ensureCachedModels();
538
- const openclawConnected = this.getOpenClawSocket() !== null;
472
+ const isDemo = isDemoUserId(doUserId2);
473
+ const openclawConnected = isDemo || this.getOpenClawSocket() !== null;
474
+ const models = this.cachedModels.length ? this.cachedModels : (isDemo ? ConnectionDO.DEMO_MODELS : []);
475
+ const model = this.defaultModel || (isDemo ? "mock/echo-1.0" : null);
539
476
  ws.send(
540
477
  JSON.stringify({
541
478
  type: "connection.status",
542
479
  openclawConnected,
543
- defaultModel: this.defaultModel,
544
- models: this.cachedModels,
545
- connectedAgents: availableAgents.filter((a) => a.status === "connected"),
480
+ defaultModel: model,
481
+ models,
546
482
  }),
547
483
  );
548
484
  return;
@@ -585,7 +521,6 @@ export class ConnectionDO implements DurableObject {
585
521
  type: msg.type,
586
522
  sessionKey: msg.sessionKey,
587
523
  messageId: msg.messageId,
588
- targetAgentId: msg.targetAgentId,
589
524
  hasMedia: !!msg.mediaUrl,
590
525
  }));
591
526
  await this.persistMessage({
@@ -595,34 +530,14 @@ export class ConnectionDO implements DurableObject {
595
530
  text: (msg.text ?? "") as string,
596
531
  mediaUrl: msg.mediaUrl as string | undefined,
597
532
  encrypted: msg.encrypted ? 1 : 0,
598
- targetAgentId: msg.targetAgentId as string | undefined,
599
533
  });
600
534
  }
601
535
 
602
- // Route user message to the target agent
603
- const targetAgentId = msg.targetAgentId as string | undefined;
604
- let targetWs: WebSocket | null = null;
605
-
606
- if (targetAgentId) {
607
- targetWs = this.getAgentSocket(targetAgentId);
608
- }
609
- if (!targetWs) {
610
- // No explicit target or target not found — try channel's default agent
611
- const sessionKey = msg.sessionKey as string | undefined;
612
- if (sessionKey) {
613
- const channelDefault = await this.resolveDefaultAgentId(sessionKey);
614
- if (channelDefault) {
615
- targetWs = this.getAgentSocket(channelDefault);
616
- }
617
- }
618
- }
619
- if (!targetWs) {
620
- // Final fallback: any connected agent (backward compat)
621
- targetWs = this.getAnyAgentSocket();
622
- }
623
-
624
- if (targetWs) {
625
- // Enrich thread messages with parent context
536
+ // Forward user messages to OpenClaw
537
+ const openclawWs = this.getOpenClawSocket();
538
+ if (openclawWs) {
539
+ // If this is a thread message, look up the parent message and attach it
540
+ // so the plugin can inject the thread-origin context into the AI conversation.
626
541
  const sessionKey = msg.sessionKey as string | undefined;
627
542
  const threadMatch = sessionKey?.match(/:thread:(.+)$/);
628
543
  let enrichedMsg = msg;
@@ -642,17 +557,20 @@ export class ConnectionDO implements DurableObject {
642
557
  parentSender: parentRow.sender as string,
643
558
  parentEncrypted: (parentRow.encrypted ?? 0) as number,
644
559
  };
560
+ console.log(`[DO] Attached parent message for thread: parentId=${parentId}, sender=${parentRow.sender}, encrypted=${parentRow.encrypted}`);
645
561
  }
646
562
  } catch (err) {
647
- console.error(`[DO] Failed to fetch parent message for thread:`, err);
563
+ console.error(`[DO] Failed to fetch parent message for thread ${parentId}:`, err);
648
564
  }
649
565
  }
650
- targetWs.send(JSON.stringify(enrichedMsg));
566
+ openclawWs.send(JSON.stringify(enrichedMsg));
567
+ } else if (await this.isDemoUser()) {
568
+ await this.handleDemoMockReply(ws, msg);
651
569
  } else {
652
570
  ws.send(
653
571
  JSON.stringify({
654
572
  type: "error",
655
- message: "No agent is currently connected. Please check your agent instances.",
573
+ message: "OpenClaw is not connected. Please check your OpenClaw instance.",
656
574
  }),
657
575
  );
658
576
  }
@@ -668,6 +586,10 @@ export class ConnectionDO implements DurableObject {
668
586
  private async handleGetScanData(): Promise<Response> {
669
587
  const openclawWs = this.getOpenClawSocket();
670
588
  if (!openclawWs) {
589
+ // Demo users get mock scan data instead of 503
590
+ if (await this.isDemoUser()) {
591
+ return Response.json({ tasks: [] });
592
+ }
671
593
  return Response.json(
672
594
  { error: "OpenClaw not connected", tasks: [] },
673
595
  { status: 503 },
@@ -708,12 +630,9 @@ export class ConnectionDO implements DurableObject {
708
630
  }
709
631
  }
710
632
 
711
- private handleStatus(): Response {
633
+ private async handleStatus(): Promise<Response> {
712
634
  const sockets = this.state.getWebSockets();
713
- const openclawSocket = sockets.find((s) => {
714
- const t = this.getTag(s);
715
- return t === "openclaw" || t?.startsWith("agent:");
716
- });
635
+ const openclawSocket = sockets.find((s) => this.getTag(s) === "openclaw");
717
636
  const browserCount = sockets.filter((s) =>
718
637
  this.getTag(s)?.startsWith("browser:"),
719
638
  ).length;
@@ -724,9 +643,10 @@ export class ConnectionDO implements DurableObject {
724
643
  openclawAuthenticated = att?.authenticated ?? false;
725
644
  }
726
645
 
646
+ const isDemo = await this.isDemoUser();
727
647
  return Response.json({
728
- openclawConnected: !!openclawSocket,
729
- openclawAuthenticated,
648
+ openclawConnected: isDemo || !!openclawSocket,
649
+ openclawAuthenticated: isDemo || openclawAuthenticated,
730
650
  browserClients: browserCount,
731
651
  });
732
652
  }
@@ -784,9 +704,11 @@ export class ConnectionDO implements DurableObject {
784
704
  return att?.tag ?? null;
785
705
  }
786
706
 
787
- /** Get a specific agent's socket by agentId. */
788
- private getAgentSocket(agentId: string): WebSocket | null {
789
- const sockets = this.state.getWebSockets("agent:" + agentId);
707
+ private getOpenClawSocket(): WebSocket | null {
708
+ const sockets = this.state.getWebSockets("openclaw");
709
+ // Return the LAST (newest) authenticated OpenClaw socket.
710
+ // After a reconnection there may briefly be multiple sockets
711
+ // before the stale cleanup in handleOpenClawMessage runs.
790
712
  let newest: WebSocket | null = null;
791
713
  for (const s of sockets) {
792
714
  const att = s.deserializeAttachment() as { authenticated: boolean } | null;
@@ -795,147 +717,6 @@ export class ConnectionDO implements DurableObject {
795
717
  return newest ?? sockets[sockets.length - 1] ?? null;
796
718
  }
797
719
 
798
- /** Get any connected agent socket (new agent:xxx or legacy openclaw). */
799
- private getAnyAgentSocket(): WebSocket | null {
800
- const allSockets = this.state.getWebSockets();
801
- for (const s of allSockets) {
802
- const att = s.deserializeAttachment() as { authenticated: boolean; tag?: string } | null;
803
- if (att?.authenticated && att?.tag?.startsWith("agent:")) return s;
804
- }
805
- const legacySockets = this.state.getWebSockets("openclaw");
806
- for (const s of legacySockets) {
807
- const att = s.deserializeAttachment() as { authenticated: boolean } | null;
808
- if (att?.authenticated) return s;
809
- }
810
- return legacySockets[legacySockets.length - 1] ?? null;
811
- }
812
-
813
- private getOpenClawSocket(): WebSocket | null {
814
- return this.getAnyAgentSocket();
815
- }
816
-
817
- // ---- Agent-to-Agent delegation ----
818
-
819
- private async handleAgentRequest(ws: WebSocket, msg: Record<string, unknown>): Promise<void> {
820
- const MAX_DEPTH = 5;
821
- const TIMEOUT_MS = 5 * 60 * 1000;
822
- const depth = (msg.depth as number) ?? 0;
823
- const requestId = msg.requestId as string;
824
- const targetAgentId = msg.targetAgentId as string;
825
- const fromAtt = ws.deserializeAttachment() as { agentId?: string } | null;
826
- const fromAgentId = (msg.agentId as string) ?? fromAtt?.agentId ?? "unknown";
827
-
828
- if (depth >= MAX_DEPTH) {
829
- const originSocket = this.getAgentSocket(fromAgentId);
830
- if (originSocket) {
831
- originSocket.send(JSON.stringify({
832
- type: "agent.response",
833
- requestId,
834
- fromAgentId: targetAgentId,
835
- error: "Maximum delegation depth exceeded",
836
- sessionKey: msg.sessionKey,
837
- }));
838
- }
839
- return;
840
- }
841
-
842
- const targetSocket = this.getAgentSocket(targetAgentId);
843
- if (!targetSocket) {
844
- // Target agent offline — notify originator and broadcast system message
845
- const originSocket = this.getAgentSocket(fromAgentId);
846
- if (originSocket) {
847
- originSocket.send(JSON.stringify({
848
- type: "agent.response",
849
- requestId,
850
- fromAgentId: targetAgentId,
851
- error: `Agent ${targetAgentId} is currently offline`,
852
- sessionKey: msg.sessionKey,
853
- }));
854
- }
855
- this.broadcastToBrowsers(JSON.stringify({
856
- type: "system.message",
857
- text: `Agent delegation failed: target agent is offline`,
858
- sessionKey: msg.sessionKey,
859
- }));
860
- return;
861
- }
862
-
863
- // Track the pending request for response routing
864
- this.pendingRequests.set(requestId, {
865
- fromAgentId,
866
- sessionKey: msg.sessionKey as string,
867
- depth,
868
- createdAt: Date.now(),
869
- });
870
-
871
- // Clean up expired pending requests
872
- const now = Date.now();
873
- for (const [rid, pr] of this.pendingRequests) {
874
- if (now - pr.createdAt > TIMEOUT_MS) {
875
- this.pendingRequests.delete(rid);
876
- }
877
- }
878
-
879
- // Persist the delegation message to D1 (visible as agent-to-agent in chat)
880
- await this.persistMessage({
881
- sender: "agent",
882
- sessionKey: msg.sessionKey as string,
883
- text: (msg.text ?? "") as string,
884
- senderAgentId: fromAgentId,
885
- targetAgentId,
886
- });
887
-
888
- // Forward to target agent as a user.message (agents don't need to know it's a delegation)
889
- targetSocket.send(JSON.stringify({
890
- type: "user.message",
891
- sessionKey: msg.sessionKey,
892
- text: msg.text,
893
- userId: fromAgentId,
894
- messageId: requestId,
895
- targetAgentId,
896
- context: msg.context,
897
- depth: depth + 1,
898
- }));
899
-
900
- // Also broadcast to browser so user can see the delegation
901
- this.broadcastToBrowsers(JSON.stringify({
902
- type: "agent.text",
903
- agentId: fromAgentId,
904
- sessionKey: msg.sessionKey,
905
- text: msg.text,
906
- targetAgentId,
907
- isDelegation: true,
908
- }));
909
- }
910
-
911
- // ---- Verbose trace persistence ----
912
-
913
- private async handleAgentTrace(msg: Record<string, unknown>): Promise<void> {
914
- try {
915
- const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
916
- const id = generateIdUtil("mt_");
917
- await this.env.DB.prepare(
918
- `INSERT INTO message_traces (id, message_id, user_id, session_key, agent_id, verbose_level, trace_type, content, metadata_json, encrypted)
919
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
920
- )
921
- .bind(
922
- id,
923
- (msg.messageId ?? "") as string,
924
- userId,
925
- (msg.sessionKey ?? "") as string,
926
- (msg.agentId ?? "") as string,
927
- (msg.verboseLevel ?? 2) as number,
928
- (msg.traceType ?? "thinking") as string,
929
- (msg.content ?? "") as string,
930
- JSON.stringify(msg.metadata ?? {}),
931
- msg.encrypted ? 1 : 0,
932
- )
933
- .run();
934
- } catch (err) {
935
- console.error("[DO] Failed to persist agent trace:", err);
936
- }
937
- }
938
-
939
720
  private broadcastToBrowsers(message: string): void {
940
721
  const sockets = this.state.getWebSockets();
941
722
  for (const s of sockets) {
@@ -1264,8 +1045,6 @@ export class ConnectionDO implements DurableObject {
1264
1045
  mediaUrl?: string;
1265
1046
  a2ui?: string;
1266
1047
  encrypted?: number;
1267
- senderAgentId?: string;
1268
- targetAgentId?: string;
1269
1048
  }): Promise<void> {
1270
1049
  try {
1271
1050
  const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
@@ -1280,10 +1059,10 @@ export class ConnectionDO implements DurableObject {
1280
1059
  }
1281
1060
 
1282
1061
  await this.env.DB.prepare(
1283
- `INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted, sender_agent_id, target_agent_id)
1284
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1062
+ `INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted)
1063
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1285
1064
  )
1286
- .bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted, opts.senderAgentId ?? null, opts.targetAgentId ?? null)
1065
+ .bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted)
1287
1066
  .run();
1288
1067
  } catch (err) {
1289
1068
  console.error("Failed to persist message:", err);
@@ -1445,7 +1224,7 @@ export class ConnectionDO implements DurableObject {
1445
1224
 
1446
1225
  // Check if a matching task already exists
1447
1226
  const existingTask = await this.env.DB.prepare(
1448
- "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1227
+ "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1449
1228
  )
1450
1229
  .bind(t.cronJobId)
1451
1230
  .first<{ id: string; session_key: string }>();
@@ -1464,7 +1243,7 @@ export class ConnectionDO implements DurableObject {
1464
1243
  t.cronJobId,
1465
1244
  ];
1466
1245
  await this.env.DB.prepare(
1467
- `UPDATE tasks SET ${updateParts.join(", ")} WHERE provider_job_id = ?`,
1246
+ `UPDATE tasks SET ${updateParts.join(", ")} WHERE openclaw_cron_job_id = ?`,
1468
1247
  )
1469
1248
  .bind(...updateVals)
1470
1249
  .run();
@@ -1483,7 +1262,7 @@ export class ConnectionDO implements DurableObject {
1483
1262
  // D1 only stores basic task metadata — schedule/instructions/model
1484
1263
  // belong to OpenClaw and are delivered via task.scan.result WebSocket.
1485
1264
  await this.env.DB.prepare(
1486
- `INSERT INTO tasks (id, channel_id, name, kind, provider_job_id, session_key, enabled)
1265
+ `INSERT INTO tasks (id, channel_id, name, kind, openclaw_cron_job_id, session_key, enabled)
1487
1266
  VALUES (?, ?, ?, 'background', ?, ?, ?)`,
1488
1267
  )
1489
1268
  .bind(taskId, defaultChannelId, taskName, t.cronJobId, sessionKey, t.enabled ? 1 : 0)
@@ -1494,7 +1273,7 @@ export class ConnectionDO implements DurableObject {
1494
1273
 
1495
1274
  // Resolve the task record for job persistence (may have just been created)
1496
1275
  const taskRecord = await this.env.DB.prepare(
1497
- "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1276
+ "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1498
1277
  )
1499
1278
  .bind(t.cronJobId)
1500
1279
  .first<{ id: string; session_key: string }>();
@@ -1558,7 +1337,7 @@ export class ConnectionDO implements DurableObject {
1558
1337
  private async ensureDefaultChannel(userId: string): Promise<string> {
1559
1338
  // Check if a default channel already exists
1560
1339
  const existing = await this.env.DB.prepare(
1561
- "SELECT id FROM channels WHERE user_id = ? AND provider_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
1340
+ "SELECT id FROM channels WHERE user_id = ? AND openclaw_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
1562
1341
  )
1563
1342
  .bind(userId)
1564
1343
  .first<{ id: string }>();
@@ -1568,7 +1347,7 @@ export class ConnectionDO implements DurableObject {
1568
1347
  // Create the default channel
1569
1348
  const channelId = this.generateId("ch_");
1570
1349
  await this.env.DB.prepare(
1571
- "INSERT INTO channels (id, user_id, name, description, provider_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
1350
+ "INSERT INTO channels (id, user_id, name, description, openclaw_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
1572
1351
  )
1573
1352
  .bind(channelId, userId)
1574
1353
  .run();
@@ -1586,101 +1365,6 @@ export class ConnectionDO implements DurableObject {
1586
1365
  return channelId;
1587
1366
  }
1588
1367
 
1589
- /**
1590
- * Resolve a "@channelName" session key to a real adhoc task session key.
1591
- * Resolve the default agent ID for a given session key.
1592
- * Looks up the channel from the session key pattern and returns its default_agent_id.
1593
- */
1594
- private async resolveDefaultAgentId(sessionKey: string): Promise<string | null> {
1595
- try {
1596
- const userId = await this.state.storage.get<string>("userId");
1597
- if (!userId) return null;
1598
-
1599
- // Try to find channel by matching session_key in sessions or tasks table
1600
- const session = await this.env.DB.prepare(
1601
- "SELECT channel_id FROM sessions WHERE session_key = ? AND user_id = ? LIMIT 1",
1602
- )
1603
- .bind(sessionKey.replace(/:thread:.+$/, ""), userId)
1604
- .first<{ channel_id: string }>();
1605
-
1606
- const channelId = session?.channel_id;
1607
- if (!channelId) return null;
1608
-
1609
- const channel = await this.env.DB.prepare(
1610
- "SELECT default_agent_id FROM channels WHERE id = ? LIMIT 1",
1611
- )
1612
- .bind(channelId)
1613
- .first<{ default_agent_id: string | null }>();
1614
-
1615
- return channel?.default_agent_id ?? null;
1616
- } catch {
1617
- return null;
1618
- }
1619
- }
1620
-
1621
- /**
1622
- * - "@default" → first channel (by created_at ASC)
1623
- * - "@SomeName" → channel matched by name (case-insensitive)
1624
- * Returns the original sessionKey if it doesn't start with "@".
1625
- */
1626
- private async resolveChannelSessionKey(rawSessionKey: string): Promise<string> {
1627
- if (!rawSessionKey.startsWith("@")) return rawSessionKey;
1628
-
1629
- const userId = await this.state.storage.get<string>("userId");
1630
- if (!userId) {
1631
- console.error("[DO] resolveChannelSessionKey: no userId in storage");
1632
- return rawSessionKey;
1633
- }
1634
-
1635
- const isDefault = rawSessionKey.toLowerCase() === "@default";
1636
- const channelName = isDefault ? null : rawSessionKey.slice(1);
1637
-
1638
- let channel: { id: string; provider_agent_id: string } | null = null;
1639
-
1640
- if (channelName) {
1641
- channel = await this.env.DB.prepare(
1642
- "SELECT id, provider_agent_id FROM channels WHERE user_id = ? AND LOWER(name) = LOWER(?) LIMIT 1",
1643
- )
1644
- .bind(userId, channelName)
1645
- .first<{ id: string; provider_agent_id: string }>();
1646
- }
1647
-
1648
- if (!channel) {
1649
- channel = await this.env.DB.prepare(
1650
- "SELECT id, provider_agent_id FROM channels WHERE user_id = ? ORDER BY created_at ASC LIMIT 1",
1651
- )
1652
- .bind(userId)
1653
- .first<{ id: string; provider_agent_id: string }>();
1654
- }
1655
-
1656
- if (!channel) {
1657
- const channelId = await this.ensureDefaultChannel(userId);
1658
- channel = { id: channelId, provider_agent_id: "main" };
1659
- }
1660
-
1661
- const task = await this.env.DB.prepare(
1662
- "SELECT session_key FROM tasks WHERE channel_id = ? AND kind = 'adhoc' LIMIT 1",
1663
- )
1664
- .bind(channel.id)
1665
- .first<{ session_key: string }>();
1666
-
1667
- if (task?.session_key) {
1668
- console.log(`[DO] Resolved ${rawSessionKey} → ${task.session_key}`);
1669
- return task.session_key;
1670
- }
1671
-
1672
- const taskId = this.generateId("tsk_");
1673
- const agentId = channel.provider_agent_id || "main";
1674
- const sessionKey = `agent:${agentId}:botschat:${userId}:adhoc`;
1675
- await this.env.DB.prepare(
1676
- "INSERT INTO tasks (id, channel_id, name, kind, session_key) VALUES (?, ?, 'Ad Hoc Chat', 'adhoc', ?)",
1677
- )
1678
- .bind(taskId, channel.id, sessionKey)
1679
- .run();
1680
- console.log(`[DO] Created adhoc task for channel ${channel.id}, resolved ${rawSessionKey} → ${sessionKey}`);
1681
- return sessionKey;
1682
- }
1683
-
1684
1368
  /** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
1685
1369
  private generateId(prefix = ""): string {
1686
1370
  return generateIdUtil(prefix);
@@ -1704,7 +1388,7 @@ export class ConnectionDO implements DurableObject {
1704
1388
 
1705
1389
  // Find the task by cronJobId
1706
1390
  const task = await this.env.DB.prepare(
1707
- "SELECT id FROM tasks WHERE provider_job_id = ?",
1391
+ "SELECT id FROM tasks WHERE openclaw_cron_job_id = ?",
1708
1392
  )
1709
1393
  .bind(cronJobId)
1710
1394
  .first<{ id: string }>();
@@ -1738,6 +1422,126 @@ export class ConnectionDO implements DurableObject {
1738
1422
  }
1739
1423
  }
1740
1424
 
1425
+ // ---- Demo mode (built-in mock OpenClaw) ----
1426
+
1427
+ private static readonly DEMO_MODELS = [
1428
+ { id: "mock/echo-1.0", name: "Echo 1.0", provider: "mock" },
1429
+ { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" },
1430
+ { id: "openai/gpt-4o", name: "GPT-4o", provider: "openai" },
1431
+ ];
1432
+
1433
+ private async isDemoUser(): Promise<boolean> {
1434
+ const userId = await this.state.storage.get<string>("userId");
1435
+ return !!userId && isDemoUserId(userId);
1436
+ }
1437
+
1438
+ /**
1439
+ * Handle a browser message when no OpenClaw plugin is connected and the
1440
+ * user is a demo account. Simulates the mock OpenClaw responses inline.
1441
+ */
1442
+ private async handleDemoMockReply(
1443
+ ws: WebSocket,
1444
+ msg: Record<string, unknown>,
1445
+ ): Promise<void> {
1446
+ const sessionKey = msg.sessionKey as string;
1447
+
1448
+ if (msg.type === "user.message") {
1449
+ const userText = (msg.text as string) ?? "";
1450
+ const replyText = `Mock reply: ${userText}`;
1451
+ const replyId = randomUUID();
1452
+
1453
+ // Small delay to feel natural
1454
+ await new Promise((r) => setTimeout(r, 300));
1455
+
1456
+ const reply = {
1457
+ type: "agent.text",
1458
+ sessionKey,
1459
+ text: replyText,
1460
+ messageId: replyId,
1461
+ };
1462
+
1463
+ await this.persistMessage({
1464
+ id: replyId,
1465
+ sender: "agent",
1466
+ sessionKey,
1467
+ text: replyText,
1468
+ encrypted: 0,
1469
+ });
1470
+
1471
+ this.broadcastToBrowsers(JSON.stringify(reply));
1472
+ } else if (msg.type === "user.media") {
1473
+ const replyId = randomUUID();
1474
+ await new Promise((r) => setTimeout(r, 300));
1475
+ const reply = {
1476
+ type: "agent.text",
1477
+ sessionKey,
1478
+ text: `📎 Received media: ${msg.mediaUrl}`,
1479
+ messageId: replyId,
1480
+ };
1481
+ await this.persistMessage({ id: replyId, sender: "agent", sessionKey, text: reply.text, encrypted: 0 });
1482
+ this.broadcastToBrowsers(JSON.stringify(reply));
1483
+ } else if (msg.type === "user.command" || msg.type === "user.action") {
1484
+ const replyId = randomUUID();
1485
+ await new Promise((r) => setTimeout(r, 200));
1486
+ const text = msg.type === "user.command"
1487
+ ? `Command received: /${msg.command} ${msg.args || ""}`.trim()
1488
+ : `Action received: ${msg.action}`;
1489
+ const reply = { type: "agent.text", sessionKey, text, messageId: replyId };
1490
+ await this.persistMessage({ id: replyId, sender: "agent", sessionKey, text, encrypted: 0 });
1491
+ this.broadcastToBrowsers(JSON.stringify(reply));
1492
+ } else if (msg.type === "task.schedule") {
1493
+ const ack = {
1494
+ type: "task.schedule.ack",
1495
+ cronJobId: (msg.cronJobId as string) || `mock_cron_${Date.now()}`,
1496
+ taskId: msg.taskId,
1497
+ ok: true,
1498
+ };
1499
+ this.broadcastToBrowsers(JSON.stringify(ack));
1500
+ } else if (msg.type === "task.run") {
1501
+ const jobId = `mock_job_${Date.now()}`;
1502
+ const startedAt = Math.floor(Date.now() / 1000);
1503
+ this.broadcastToBrowsers(JSON.stringify({
1504
+ type: "job.update",
1505
+ cronJobId: msg.cronJobId,
1506
+ jobId,
1507
+ sessionKey: sessionKey ?? "",
1508
+ status: "running",
1509
+ startedAt,
1510
+ }));
1511
+ await new Promise((r) => setTimeout(r, 2000));
1512
+ const finishedAt = Math.floor(Date.now() / 1000);
1513
+ const update = {
1514
+ type: "job.update",
1515
+ cronJobId: msg.cronJobId,
1516
+ jobId,
1517
+ sessionKey: sessionKey ?? "",
1518
+ status: "ok",
1519
+ summary: "Mock task executed successfully",
1520
+ startedAt,
1521
+ finishedAt,
1522
+ durationMs: (finishedAt - startedAt) * 1000,
1523
+ };
1524
+ await this.handleJobUpdate(update);
1525
+ this.broadcastToBrowsers(JSON.stringify(update));
1526
+ } else if (msg.type === "settings.defaultModel") {
1527
+ const model = (msg.defaultModel as string) ?? "";
1528
+ if (model) {
1529
+ this.defaultModel = model;
1530
+ await this.state.storage.put("defaultModel", model);
1531
+ }
1532
+ this.broadcastToBrowsers(JSON.stringify({
1533
+ type: "defaultModel.updated",
1534
+ model,
1535
+ }));
1536
+ this.broadcastToBrowsers(JSON.stringify({
1537
+ type: "connection.status",
1538
+ openclawConnected: true,
1539
+ defaultModel: this.defaultModel,
1540
+ models: this.cachedModels.length ? this.cachedModels : ConnectionDO.DEMO_MODELS,
1541
+ }));
1542
+ }
1543
+ }
1544
+
1741
1545
  private async validatePairingToken(token: string): Promise<boolean> {
1742
1546
  // The API worker validates pairing tokens against D1 before routing
1743
1547
  // to the DO (and passes ?verified=1). Connections that arrive here