botschat 0.1.19 → 0.1.20

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 (56) hide show
  1. package/migrations/0013_agents_table.sql +29 -0
  2. package/migrations/0014_agent_sessions.sql +19 -0
  3. package/migrations/0015_message_traces.sql +27 -0
  4. package/migrations/0016_multi_agent_channels_messages.sql +9 -0
  5. package/migrations/0017_rename_cron_job_id.sql +2 -0
  6. package/package.json +1 -1
  7. package/packages/api/src/do/connection-do.ts +382 -186
  8. package/packages/api/src/index.ts +67 -50
  9. package/packages/api/src/protocol-v2.ts +154 -0
  10. package/packages/api/src/routes/agents-v2.ts +192 -0
  11. package/packages/api/src/routes/agents.ts +3 -3
  12. package/packages/api/src/routes/auth.ts +0 -1
  13. package/packages/api/src/routes/channels.ts +11 -11
  14. package/packages/api/src/routes/history-v2.ts +221 -0
  15. package/packages/api/src/routes/migrate-v2.ts +110 -0
  16. package/packages/api/src/routes/sessions.ts +5 -5
  17. package/packages/api/src/routes/tasks.ts +33 -33
  18. package/packages/plugin/dist/src/channel.d.ts +10 -0
  19. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  20. package/packages/plugin/dist/src/channel.js +10 -51
  21. package/packages/plugin/dist/src/channel.js.map +1 -1
  22. package/packages/plugin/dist/src/types.d.ts +13 -0
  23. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  24. package/packages/plugin/package.json +2 -18
  25. package/packages/web/dist/assets/index-BARPtt0v.css +1 -0
  26. package/packages/web/dist/assets/index-Bf-XL3te.js +2 -0
  27. package/packages/web/dist/assets/{index-C_GamcQc.js → index-CYQMu_-c.js} +1 -1
  28. package/packages/web/dist/assets/index-CYlvfpX9.js +1519 -0
  29. package/packages/web/dist/assets/index-CxcpA4Qo.js +1 -0
  30. package/packages/web/dist/assets/{index-MyoWvQAH.js → index-DYCO-ry1.js} +1 -1
  31. package/packages/web/dist/assets/index-QebPVqwj.js +2 -0
  32. package/packages/web/dist/assets/{index.esm-BpQAwtdR.js → index.esm-CvOpngZM.js} +1 -1
  33. package/packages/web/dist/assets/{web-cnzjgNfD.js → web-1cdhq2RW.js} +1 -1
  34. package/packages/web/dist/assets/{web-BbTzVNLt.js → web-D3LMODYp.js} +1 -1
  35. package/packages/web/dist/index.html +2 -2
  36. package/packages/web/src/App.tsx +56 -9
  37. package/packages/web/src/api.ts +61 -5
  38. package/packages/web/src/components/AgentSettings.tsx +328 -0
  39. package/packages/web/src/components/ChatWindow.tsx +9 -9
  40. package/packages/web/src/components/CronDetail.tsx +1 -1
  41. package/packages/web/src/components/LoginPage.tsx +1 -59
  42. package/packages/web/src/components/MessageContent.tsx +2 -17
  43. package/packages/web/src/components/SessionTabs.tsx +1 -1
  44. package/packages/web/src/components/Sidebar.tsx +3 -1
  45. package/packages/web/src/hooks/useIMEComposition.ts +9 -14
  46. package/packages/web/src/store.ts +39 -7
  47. package/packages/web/src/ws.ts +1 -0
  48. package/scripts/dev.sh +53 -0
  49. package/scripts/mock-openclaw-v2.mjs +486 -0
  50. package/packages/api/src/routes/demo.ts +0 -156
  51. package/packages/web/dist/assets/index-BtPyCBCl.css +0 -1
  52. package/packages/web/dist/assets/index-BtpsFe4Z.js +0 -2
  53. package/packages/web/dist/assets/index-CQbIYr6_.js +0 -2
  54. package/packages/web/dist/assets/index-LiBjPMg2.js +0 -1
  55. package/packages/web/dist/assets/index-STIPTMK8.js +0 -1516
  56. package/packages/web/src/components/ImageLightbox.tsx +0 -96
@@ -4,7 +4,6 @@ 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";
8
7
 
9
8
  /** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
10
9
  interface BrowserAttachment {
@@ -30,7 +29,8 @@ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
30
29
  * - Use WebSocket Hibernation API so idle users cost zero compute
31
30
  *
32
31
  * Connection tagging (via serializeAttachment / deserializeAttachment):
33
- * - "openclaw" = the WebSocket from the OpenClaw plugin
32
+ * - "agent:<agentId>" = WebSocket from an agent plugin (v2, per-agent)
33
+ * - "openclaw" = legacy WebSocket from the OpenClaw plugin (backward compat)
34
34
  * - "browser:<sessionId>" = a browser client WebSocket
35
35
  */
36
36
  export class ConnectionDO implements DurableObject {
@@ -54,6 +54,9 @@ 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
+
57
60
  constructor(state: DurableObjectState, env: Env) {
58
61
  this.state = state;
59
62
  this.env = env;
@@ -89,7 +92,8 @@ export class ConnectionDO implements DurableObject {
89
92
  }
90
93
  }
91
94
  const preVerified = url.searchParams.get("verified") === "1";
92
- return this.handleOpenClawConnect(request, preVerified);
95
+ const agentId = url.searchParams.get("agentId");
96
+ return this.handleOpenClawConnect(request, preVerified, agentId);
93
97
  }
94
98
 
95
99
  // Route: /client/:sessionId — Browser client connects here
@@ -105,9 +109,8 @@ export class ConnectionDO implements DurableObject {
105
109
  // Route: /models — Available models (REST)
106
110
  if (url.pathname === "/models") {
107
111
  await this.ensureCachedModels();
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 });
112
+ console.log(`[DO] GET /models returning ${this.cachedModels.length} models`);
113
+ return Response.json({ models: this.cachedModels });
111
114
  }
112
115
 
113
116
  // Route: /scan-data — Cached OpenClaw scan data (schedule/instructions/model)
@@ -143,7 +146,7 @@ export class ConnectionDO implements DurableObject {
143
146
  return;
144
147
  }
145
148
 
146
- if (tag === "openclaw") {
149
+ if (tag === "openclaw" || tag?.startsWith("agent:")) {
147
150
  await this.handleOpenClawMessage(ws, parsed);
148
151
  } else if (tag?.startsWith("browser:")) {
149
152
  await this.handleBrowserMessage(ws, parsed);
@@ -164,8 +167,7 @@ export class ConnectionDO implements DurableObject {
164
167
  /** Called when a WebSocket is closed. */
165
168
  async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise<void> {
166
169
  const tag = this.getTag(ws);
167
- if (tag === "openclaw") {
168
- // OpenClaw disconnected — notify all browser clients
170
+ if (tag === "openclaw" || tag?.startsWith("agent:")) {
169
171
  this.broadcastToBrowsers(
170
172
  JSON.stringify({ type: "openclaw.disconnected" }),
171
173
  );
@@ -188,7 +190,7 @@ export class ConnectionDO implements DurableObject {
188
190
 
189
191
  // ---- Connection handlers ----
190
192
 
191
- private handleOpenClawConnect(request: Request, preVerified = false): Response {
193
+ private handleOpenClawConnect(request: Request, preVerified = false, agentId?: string | null): Response {
192
194
  if (request.headers.get("Upgrade") !== "websocket") {
193
195
  return new Response("Expected WebSocket upgrade", { status: 426 });
194
196
  }
@@ -204,12 +206,14 @@ export class ConnectionDO implements DurableObject {
204
206
  }
205
207
  this.lastOpenClawAcceptedAt = now;
206
208
 
207
- // Safety valve: if stale openclaw sockets accumulated (e.g. from
209
+ const wsTag = agentId ? "agent:" + agentId : "openclaw";
210
+
211
+ // Safety valve: if stale sockets with the same tag accumulated (e.g. from
208
212
  // rapid reconnects that authenticated but then lost their edge
209
213
  // connection), close them all before accepting a new one.
210
- const existing = this.state.getWebSockets("openclaw");
214
+ const existing = this.state.getWebSockets(wsTag);
211
215
  if (existing.length > 3) {
212
- console.warn(`[DO] Safety valve: ${existing.length} openclaw sockets, closing all`);
216
+ console.warn(`[DO] Safety valve: ${existing.length} ${wsTag} sockets, closing all`);
213
217
  for (const s of existing) {
214
218
  try { s.close(4009, "replaced"); } catch { /* dead */ }
215
219
  }
@@ -218,12 +222,12 @@ export class ConnectionDO implements DurableObject {
218
222
  const pair = new WebSocketPair();
219
223
  const [client, server] = [pair[0], pair[1]];
220
224
 
221
- this.state.acceptWebSocket(server, ["openclaw"]);
225
+ this.state.acceptWebSocket(server, [wsTag]);
222
226
 
223
227
  // If the API worker already verified the token against D1, mark as
224
228
  // pre-verified. The plugin still sends an auth message, which we'll
225
229
  // fast-track through without re-validating the token.
226
- server.serializeAttachment({ authenticated: false, tag: "openclaw", preVerified });
230
+ server.serializeAttachment({ authenticated: false, tag: wsTag, agentId: agentId ?? null, preVerified });
227
231
 
228
232
  return new Response(null, { status: 101, webSocket: client });
229
233
  }
@@ -272,11 +276,12 @@ export class ConnectionDO implements DurableObject {
272
276
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
273
277
 
274
278
  if (isValid) {
275
- // Close ALL other openclaw sockets. Use custom code 4009 so
279
+ // Close other sockets with the SAME tag. Use custom code 4009 so
276
280
  // well-behaved plugins know they were replaced (not a crash)
277
281
  // and should NOT reconnect. The Worker-level rate limit (10s)
278
282
  // prevents the resulting close event from flooding the DO.
279
- const existingSockets = this.state.getWebSockets("openclaw");
283
+ const wsTag = attachment?.tag ?? "openclaw";
284
+ const existingSockets = this.state.getWebSockets(wsTag);
280
285
  let closedCount = 0;
281
286
  for (const oldWs of existingSockets) {
282
287
  if (oldWs !== ws) {
@@ -318,6 +323,15 @@ export class ConnectionDO implements DurableObject {
318
323
  return;
319
324
  }
320
325
 
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
+
321
335
  // Persist agent messages to D1 (skip transient stream events)
322
336
  if (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") {
323
337
  console.log("[DO] Agent outbound:", JSON.stringify({
@@ -343,6 +357,9 @@ export class ConnectionDO implements DurableObject {
343
357
  // Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
344
358
  const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
345
359
 
360
+ // Extract agentId from the sending WebSocket's attachment
361
+ const wsAtt = ws.deserializeAttachment() as { agentId?: string } | null;
362
+
346
363
  await this.persistMessage({
347
364
  id: msg.messageId as string | undefined,
348
365
  sender: "agent",
@@ -352,6 +369,7 @@ export class ConnectionDO implements DurableObject {
352
369
  mediaUrl: persistedMediaUrl,
353
370
  a2ui: msg.jsonl as string | undefined,
354
371
  encrypted: encryptedBits,
372
+ senderAgentId: (msg.agentId as string | undefined) ?? wsAtt?.agentId ?? undefined,
355
373
  });
356
374
  }
357
375
 
@@ -365,7 +383,7 @@ export class ConnectionDO implements DurableObject {
365
383
  if (msg.type === "task.schedule.ack" && msg.ok && msg.taskId && msg.cronJobId) {
366
384
  try {
367
385
  await this.env.DB.prepare(
368
- "UPDATE tasks SET openclaw_cron_job_id = ? WHERE id = ?",
386
+ "UPDATE tasks SET provider_job_id = ? WHERE id = ?",
369
387
  ).bind(msg.cronJobId, msg.taskId).run();
370
388
  console.log(`[DO] Updated task ${msg.taskId} with cronJobId ${msg.cronJobId}`);
371
389
  } catch (err) {
@@ -406,11 +424,44 @@ export class ConnectionDO implements DurableObject {
406
424
  await this.handleJobUpdate(msg);
407
425
  }
408
426
 
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
+
409
439
  // Forward all messages to browser clients (strip notifyPreview — plaintext
410
440
  // must not be relayed to browser WebSockets; browsers decrypt locally)
411
441
  if (msg.type === "agent.text") {
412
442
  console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
413
443
  }
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
+
414
465
  const { notifyPreview: _stripped, ...msgForBrowser } = msg;
415
466
  this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
416
467
 
@@ -458,27 +509,40 @@ export class ConnectionDO implements DurableObject {
458
509
  }
459
510
 
460
511
  ws.serializeAttachment({ ...attachment, authenticated: true });
461
- // Include userId so the browser can derive the E2E key
462
512
  const doUserId2 = doUserId ?? payload.sub;
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);
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);
467
532
  }
468
- ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2 }));
469
533
 
470
- // Send current OpenClaw connection status + cached models
534
+ ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2, availableAgents }));
535
+
536
+ // Send current connection status + cached models
471
537
  await this.ensureCachedModels();
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);
538
+ const openclawConnected = this.getOpenClawSocket() !== null;
476
539
  ws.send(
477
540
  JSON.stringify({
478
541
  type: "connection.status",
479
542
  openclawConnected,
480
- defaultModel: model,
481
- models,
543
+ defaultModel: this.defaultModel,
544
+ models: this.cachedModels,
545
+ connectedAgents: availableAgents.filter((a) => a.status === "connected"),
482
546
  }),
483
547
  );
484
548
  return;
@@ -521,6 +585,7 @@ export class ConnectionDO implements DurableObject {
521
585
  type: msg.type,
522
586
  sessionKey: msg.sessionKey,
523
587
  messageId: msg.messageId,
588
+ targetAgentId: msg.targetAgentId,
524
589
  hasMedia: !!msg.mediaUrl,
525
590
  }));
526
591
  await this.persistMessage({
@@ -530,14 +595,34 @@ export class ConnectionDO implements DurableObject {
530
595
  text: (msg.text ?? "") as string,
531
596
  mediaUrl: msg.mediaUrl as string | undefined,
532
597
  encrypted: msg.encrypted ? 1 : 0,
598
+ targetAgentId: msg.targetAgentId as string | undefined,
533
599
  });
534
600
  }
535
601
 
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.
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
541
626
  const sessionKey = msg.sessionKey as string | undefined;
542
627
  const threadMatch = sessionKey?.match(/:thread:(.+)$/);
543
628
  let enrichedMsg = msg;
@@ -557,20 +642,17 @@ export class ConnectionDO implements DurableObject {
557
642
  parentSender: parentRow.sender as string,
558
643
  parentEncrypted: (parentRow.encrypted ?? 0) as number,
559
644
  };
560
- console.log(`[DO] Attached parent message for thread: parentId=${parentId}, sender=${parentRow.sender}, encrypted=${parentRow.encrypted}`);
561
645
  }
562
646
  } catch (err) {
563
- console.error(`[DO] Failed to fetch parent message for thread ${parentId}:`, err);
647
+ console.error(`[DO] Failed to fetch parent message for thread:`, err);
564
648
  }
565
649
  }
566
- openclawWs.send(JSON.stringify(enrichedMsg));
567
- } else if (await this.isDemoUser()) {
568
- await this.handleDemoMockReply(ws, msg);
650
+ targetWs.send(JSON.stringify(enrichedMsg));
569
651
  } else {
570
652
  ws.send(
571
653
  JSON.stringify({
572
654
  type: "error",
573
- message: "OpenClaw is not connected. Please check your OpenClaw instance.",
655
+ message: "No agent is currently connected. Please check your agent instances.",
574
656
  }),
575
657
  );
576
658
  }
@@ -586,10 +668,6 @@ export class ConnectionDO implements DurableObject {
586
668
  private async handleGetScanData(): Promise<Response> {
587
669
  const openclawWs = this.getOpenClawSocket();
588
670
  if (!openclawWs) {
589
- // Demo users get mock scan data instead of 503
590
- if (await this.isDemoUser()) {
591
- return Response.json({ tasks: [] });
592
- }
593
671
  return Response.json(
594
672
  { error: "OpenClaw not connected", tasks: [] },
595
673
  { status: 503 },
@@ -630,9 +708,12 @@ export class ConnectionDO implements DurableObject {
630
708
  }
631
709
  }
632
710
 
633
- private async handleStatus(): Promise<Response> {
711
+ private handleStatus(): Response {
634
712
  const sockets = this.state.getWebSockets();
635
- const openclawSocket = sockets.find((s) => this.getTag(s) === "openclaw");
713
+ const openclawSocket = sockets.find((s) => {
714
+ const t = this.getTag(s);
715
+ return t === "openclaw" || t?.startsWith("agent:");
716
+ });
636
717
  const browserCount = sockets.filter((s) =>
637
718
  this.getTag(s)?.startsWith("browser:"),
638
719
  ).length;
@@ -643,10 +724,9 @@ export class ConnectionDO implements DurableObject {
643
724
  openclawAuthenticated = att?.authenticated ?? false;
644
725
  }
645
726
 
646
- const isDemo = await this.isDemoUser();
647
727
  return Response.json({
648
- openclawConnected: isDemo || !!openclawSocket,
649
- openclawAuthenticated: isDemo || openclawAuthenticated,
728
+ openclawConnected: !!openclawSocket,
729
+ openclawAuthenticated,
650
730
  browserClients: browserCount,
651
731
  });
652
732
  }
@@ -704,11 +784,9 @@ export class ConnectionDO implements DurableObject {
704
784
  return att?.tag ?? null;
705
785
  }
706
786
 
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.
787
+ /** Get a specific agent's socket by agentId. */
788
+ private getAgentSocket(agentId: string): WebSocket | null {
789
+ const sockets = this.state.getWebSockets("agent:" + agentId);
712
790
  let newest: WebSocket | null = null;
713
791
  for (const s of sockets) {
714
792
  const att = s.deserializeAttachment() as { authenticated: boolean } | null;
@@ -717,6 +795,147 @@ export class ConnectionDO implements DurableObject {
717
795
  return newest ?? sockets[sockets.length - 1] ?? null;
718
796
  }
719
797
 
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
+
720
939
  private broadcastToBrowsers(message: string): void {
721
940
  const sockets = this.state.getWebSockets();
722
941
  for (const s of sockets) {
@@ -1045,6 +1264,8 @@ export class ConnectionDO implements DurableObject {
1045
1264
  mediaUrl?: string;
1046
1265
  a2ui?: string;
1047
1266
  encrypted?: number;
1267
+ senderAgentId?: string;
1268
+ targetAgentId?: string;
1048
1269
  }): Promise<void> {
1049
1270
  try {
1050
1271
  const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
@@ -1059,10 +1280,10 @@ export class ConnectionDO implements DurableObject {
1059
1280
  }
1060
1281
 
1061
1282
  await this.env.DB.prepare(
1062
- `INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted)
1063
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1064
1285
  )
1065
- .bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted)
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)
1066
1287
  .run();
1067
1288
  } catch (err) {
1068
1289
  console.error("Failed to persist message:", err);
@@ -1224,7 +1445,7 @@ export class ConnectionDO implements DurableObject {
1224
1445
 
1225
1446
  // Check if a matching task already exists
1226
1447
  const existingTask = await this.env.DB.prepare(
1227
- "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1448
+ "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1228
1449
  )
1229
1450
  .bind(t.cronJobId)
1230
1451
  .first<{ id: string; session_key: string }>();
@@ -1243,7 +1464,7 @@ export class ConnectionDO implements DurableObject {
1243
1464
  t.cronJobId,
1244
1465
  ];
1245
1466
  await this.env.DB.prepare(
1246
- `UPDATE tasks SET ${updateParts.join(", ")} WHERE openclaw_cron_job_id = ?`,
1467
+ `UPDATE tasks SET ${updateParts.join(", ")} WHERE provider_job_id = ?`,
1247
1468
  )
1248
1469
  .bind(...updateVals)
1249
1470
  .run();
@@ -1262,7 +1483,7 @@ export class ConnectionDO implements DurableObject {
1262
1483
  // D1 only stores basic task metadata — schedule/instructions/model
1263
1484
  // belong to OpenClaw and are delivered via task.scan.result WebSocket.
1264
1485
  await this.env.DB.prepare(
1265
- `INSERT INTO tasks (id, channel_id, name, kind, openclaw_cron_job_id, session_key, enabled)
1486
+ `INSERT INTO tasks (id, channel_id, name, kind, provider_job_id, session_key, enabled)
1266
1487
  VALUES (?, ?, ?, 'background', ?, ?, ?)`,
1267
1488
  )
1268
1489
  .bind(taskId, defaultChannelId, taskName, t.cronJobId, sessionKey, t.enabled ? 1 : 0)
@@ -1273,7 +1494,7 @@ export class ConnectionDO implements DurableObject {
1273
1494
 
1274
1495
  // Resolve the task record for job persistence (may have just been created)
1275
1496
  const taskRecord = await this.env.DB.prepare(
1276
- "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1497
+ "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1277
1498
  )
1278
1499
  .bind(t.cronJobId)
1279
1500
  .first<{ id: string; session_key: string }>();
@@ -1337,7 +1558,7 @@ export class ConnectionDO implements DurableObject {
1337
1558
  private async ensureDefaultChannel(userId: string): Promise<string> {
1338
1559
  // Check if a default channel already exists
1339
1560
  const existing = await this.env.DB.prepare(
1340
- "SELECT id FROM channels WHERE user_id = ? AND openclaw_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
1561
+ "SELECT id FROM channels WHERE user_id = ? AND provider_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
1341
1562
  )
1342
1563
  .bind(userId)
1343
1564
  .first<{ id: string }>();
@@ -1347,7 +1568,7 @@ export class ConnectionDO implements DurableObject {
1347
1568
  // Create the default channel
1348
1569
  const channelId = this.generateId("ch_");
1349
1570
  await this.env.DB.prepare(
1350
- "INSERT INTO channels (id, user_id, name, description, openclaw_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
1571
+ "INSERT INTO channels (id, user_id, name, description, provider_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
1351
1572
  )
1352
1573
  .bind(channelId, userId)
1353
1574
  .run();
@@ -1365,6 +1586,101 @@ export class ConnectionDO implements DurableObject {
1365
1586
  return channelId;
1366
1587
  }
1367
1588
 
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
+
1368
1684
  /** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
1369
1685
  private generateId(prefix = ""): string {
1370
1686
  return generateIdUtil(prefix);
@@ -1388,7 +1704,7 @@ export class ConnectionDO implements DurableObject {
1388
1704
 
1389
1705
  // Find the task by cronJobId
1390
1706
  const task = await this.env.DB.prepare(
1391
- "SELECT id FROM tasks WHERE openclaw_cron_job_id = ?",
1707
+ "SELECT id FROM tasks WHERE provider_job_id = ?",
1392
1708
  )
1393
1709
  .bind(cronJobId)
1394
1710
  .first<{ id: string }>();
@@ -1422,126 +1738,6 @@ export class ConnectionDO implements DurableObject {
1422
1738
  }
1423
1739
  }
1424
1740
 
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
-
1545
1741
  private async validatePairingToken(token: string): Promise<boolean> {
1546
1742
  // The API worker validates pairing tokens against D1 before routing
1547
1743
  // to the DO (and passes ?verified=1). Connections that arrive here