botschat 0.1.18 → 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 (59) hide show
  1. package/README.md +4 -0
  2. package/migrations/0013_agents_table.sql +29 -0
  3. package/migrations/0014_agent_sessions.sql +19 -0
  4. package/migrations/0015_message_traces.sql +27 -0
  5. package/migrations/0016_multi_agent_channels_messages.sql +9 -0
  6. package/migrations/0017_rename_cron_job_id.sql +2 -0
  7. package/package.json +1 -1
  8. package/packages/api/src/do/connection-do.ts +375 -42
  9. package/packages/api/src/index.ts +67 -24
  10. package/packages/api/src/protocol-v2.ts +154 -0
  11. package/packages/api/src/routes/agents-v2.ts +192 -0
  12. package/packages/api/src/routes/agents.ts +3 -3
  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/index.d.ts +1 -0
  19. package/packages/plugin/dist/index.d.ts.map +1 -1
  20. package/packages/plugin/dist/index.js +2 -1
  21. package/packages/plugin/dist/index.js.map +1 -1
  22. package/packages/plugin/dist/src/channel.d.ts +10 -0
  23. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  24. package/packages/plugin/dist/src/channel.js +311 -69
  25. package/packages/plugin/dist/src/channel.js.map +1 -1
  26. package/packages/plugin/dist/src/runtime.d.ts +2 -0
  27. package/packages/plugin/dist/src/runtime.d.ts.map +1 -1
  28. package/packages/plugin/dist/src/runtime.js +10 -0
  29. package/packages/plugin/dist/src/runtime.js.map +1 -1
  30. package/packages/plugin/dist/src/types.d.ts +25 -0
  31. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  32. package/packages/plugin/package.json +1 -1
  33. package/packages/web/dist/assets/{index-B5GU1yVt.css → index-BARPtt0v.css} +1 -1
  34. package/packages/web/dist/assets/index-Bf-XL3te.js +2 -0
  35. package/packages/web/dist/assets/{index-DzYqprDN.js → index-CYQMu_-c.js} +1 -1
  36. package/packages/web/dist/assets/index-CYlvfpX9.js +1519 -0
  37. package/packages/web/dist/assets/index-CxcpA4Qo.js +1 -0
  38. package/packages/web/dist/assets/{index-D3T7sc-R.js → index-DYCO-ry1.js} +1 -1
  39. package/packages/web/dist/assets/index-QebPVqwj.js +2 -0
  40. package/packages/web/dist/assets/{index.esm-COzWPkKi.js → index.esm-CvOpngZM.js} +1 -1
  41. package/packages/web/dist/assets/{web-CxXbaApe.js → web-1cdhq2RW.js} +1 -1
  42. package/packages/web/dist/assets/{web-DFQypSd0.js → web-D3LMODYp.js} +1 -1
  43. package/packages/web/dist/index.html +2 -2
  44. package/packages/web/src/App.tsx +84 -5
  45. package/packages/web/src/api.ts +61 -3
  46. package/packages/web/src/components/AgentSettings.tsx +328 -0
  47. package/packages/web/src/components/ChatWindow.tsx +124 -4
  48. package/packages/web/src/components/CronDetail.tsx +1 -1
  49. package/packages/web/src/components/SessionTabs.tsx +1 -1
  50. package/packages/web/src/components/Sidebar.tsx +3 -1
  51. package/packages/web/src/store.ts +86 -11
  52. package/packages/web/src/ws.ts +22 -1
  53. package/scripts/dev.sh +53 -0
  54. package/scripts/mock-openclaw-v2.mjs +486 -0
  55. package/scripts/mock-openclaw.mjs +35 -0
  56. package/packages/web/dist/assets/index-CO9YgLst.js +0 -2
  57. package/packages/web/dist/assets/index-ClDrCe_c.js +0 -1
  58. package/packages/web/dist/assets/index-DPEosppm.js +0 -2
  59. package/packages/web/dist/assets/index-IVUdSd9w.js +0 -1516
@@ -29,7 +29,8 @@ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
29
29
  * - Use WebSocket Hibernation API so idle users cost zero compute
30
30
  *
31
31
  * Connection tagging (via serializeAttachment / deserializeAttachment):
32
- * - "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)
33
34
  * - "browser:<sessionId>" = a browser client WebSocket
34
35
  */
35
36
  export class ConnectionDO implements DurableObject {
@@ -53,6 +54,9 @@ export class ConnectionDO implements DurableObject {
53
54
  /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
54
55
  private lastOpenClawAcceptedAt = 0;
55
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
+
56
60
  constructor(state: DurableObjectState, env: Env) {
57
61
  this.state = state;
58
62
  this.env = env;
@@ -88,7 +92,8 @@ export class ConnectionDO implements DurableObject {
88
92
  }
89
93
  }
90
94
  const preVerified = url.searchParams.get("verified") === "1";
91
- return this.handleOpenClawConnect(request, preVerified);
95
+ const agentId = url.searchParams.get("agentId");
96
+ return this.handleOpenClawConnect(request, preVerified, agentId);
92
97
  }
93
98
 
94
99
  // Route: /client/:sessionId — Browser client connects here
@@ -141,7 +146,7 @@ export class ConnectionDO implements DurableObject {
141
146
  return;
142
147
  }
143
148
 
144
- if (tag === "openclaw") {
149
+ if (tag === "openclaw" || tag?.startsWith("agent:")) {
145
150
  await this.handleOpenClawMessage(ws, parsed);
146
151
  } else if (tag?.startsWith("browser:")) {
147
152
  await this.handleBrowserMessage(ws, parsed);
@@ -162,8 +167,7 @@ export class ConnectionDO implements DurableObject {
162
167
  /** Called when a WebSocket is closed. */
163
168
  async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise<void> {
164
169
  const tag = this.getTag(ws);
165
- if (tag === "openclaw") {
166
- // OpenClaw disconnected — notify all browser clients
170
+ if (tag === "openclaw" || tag?.startsWith("agent:")) {
167
171
  this.broadcastToBrowsers(
168
172
  JSON.stringify({ type: "openclaw.disconnected" }),
169
173
  );
@@ -186,7 +190,7 @@ export class ConnectionDO implements DurableObject {
186
190
 
187
191
  // ---- Connection handlers ----
188
192
 
189
- private handleOpenClawConnect(request: Request, preVerified = false): Response {
193
+ private handleOpenClawConnect(request: Request, preVerified = false, agentId?: string | null): Response {
190
194
  if (request.headers.get("Upgrade") !== "websocket") {
191
195
  return new Response("Expected WebSocket upgrade", { status: 426 });
192
196
  }
@@ -202,12 +206,14 @@ export class ConnectionDO implements DurableObject {
202
206
  }
203
207
  this.lastOpenClawAcceptedAt = now;
204
208
 
205
- // 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
206
212
  // rapid reconnects that authenticated but then lost their edge
207
213
  // connection), close them all before accepting a new one.
208
- const existing = this.state.getWebSockets("openclaw");
214
+ const existing = this.state.getWebSockets(wsTag);
209
215
  if (existing.length > 3) {
210
- console.warn(`[DO] Safety valve: ${existing.length} openclaw sockets, closing all`);
216
+ console.warn(`[DO] Safety valve: ${existing.length} ${wsTag} sockets, closing all`);
211
217
  for (const s of existing) {
212
218
  try { s.close(4009, "replaced"); } catch { /* dead */ }
213
219
  }
@@ -216,12 +222,12 @@ export class ConnectionDO implements DurableObject {
216
222
  const pair = new WebSocketPair();
217
223
  const [client, server] = [pair[0], pair[1]];
218
224
 
219
- this.state.acceptWebSocket(server, ["openclaw"]);
225
+ this.state.acceptWebSocket(server, [wsTag]);
220
226
 
221
227
  // If the API worker already verified the token against D1, mark as
222
228
  // pre-verified. The plugin still sends an auth message, which we'll
223
229
  // fast-track through without re-validating the token.
224
- server.serializeAttachment({ authenticated: false, tag: "openclaw", preVerified });
230
+ server.serializeAttachment({ authenticated: false, tag: wsTag, agentId: agentId ?? null, preVerified });
225
231
 
226
232
  return new Response(null, { status: 101, webSocket: client });
227
233
  }
@@ -270,11 +276,12 @@ export class ConnectionDO implements DurableObject {
270
276
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
271
277
 
272
278
  if (isValid) {
273
- // Close ALL other openclaw sockets. Use custom code 4009 so
279
+ // Close other sockets with the SAME tag. Use custom code 4009 so
274
280
  // well-behaved plugins know they were replaced (not a crash)
275
281
  // and should NOT reconnect. The Worker-level rate limit (10s)
276
282
  // prevents the resulting close event from flooding the DO.
277
- const existingSockets = this.state.getWebSockets("openclaw");
283
+ const wsTag = attachment?.tag ?? "openclaw";
284
+ const existingSockets = this.state.getWebSockets(wsTag);
278
285
  let closedCount = 0;
279
286
  for (const oldWs of existingSockets) {
280
287
  if (oldWs !== ws) {
@@ -316,6 +323,15 @@ export class ConnectionDO implements DurableObject {
316
323
  return;
317
324
  }
318
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
+
319
335
  // Persist agent messages to D1 (skip transient stream events)
320
336
  if (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") {
321
337
  console.log("[DO] Agent outbound:", JSON.stringify({
@@ -341,6 +357,9 @@ export class ConnectionDO implements DurableObject {
341
357
  // Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
342
358
  const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
343
359
 
360
+ // Extract agentId from the sending WebSocket's attachment
361
+ const wsAtt = ws.deserializeAttachment() as { agentId?: string } | null;
362
+
344
363
  await this.persistMessage({
345
364
  id: msg.messageId as string | undefined,
346
365
  sender: "agent",
@@ -350,6 +369,7 @@ export class ConnectionDO implements DurableObject {
350
369
  mediaUrl: persistedMediaUrl,
351
370
  a2ui: msg.jsonl as string | undefined,
352
371
  encrypted: encryptedBits,
372
+ senderAgentId: (msg.agentId as string | undefined) ?? wsAtt?.agentId ?? undefined,
353
373
  });
354
374
  }
355
375
 
@@ -363,7 +383,7 @@ export class ConnectionDO implements DurableObject {
363
383
  if (msg.type === "task.schedule.ack" && msg.ok && msg.taskId && msg.cronJobId) {
364
384
  try {
365
385
  await this.env.DB.prepare(
366
- "UPDATE tasks SET openclaw_cron_job_id = ? WHERE id = ?",
386
+ "UPDATE tasks SET provider_job_id = ? WHERE id = ?",
367
387
  ).bind(msg.cronJobId, msg.taskId).run();
368
388
  console.log(`[DO] Updated task ${msg.taskId} with cronJobId ${msg.cronJobId}`);
369
389
  } catch (err) {
@@ -404,11 +424,44 @@ export class ConnectionDO implements DurableObject {
404
424
  await this.handleJobUpdate(msg);
405
425
  }
406
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
+
407
439
  // Forward all messages to browser clients (strip notifyPreview — plaintext
408
440
  // must not be relayed to browser WebSockets; browsers decrypt locally)
409
441
  if (msg.type === "agent.text") {
410
442
  console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
411
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
+
412
465
  const { notifyPreview: _stripped, ...msgForBrowser } = msg;
413
466
  this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
414
467
 
@@ -456,11 +509,31 @@ export class ConnectionDO implements DurableObject {
456
509
  }
457
510
 
458
511
  ws.serializeAttachment({ ...attachment, authenticated: true });
459
- // Include userId so the browser can derive the E2E key
460
512
  const doUserId2 = doUserId ?? payload.sub;
461
- ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2 }));
462
513
 
463
- // Send current OpenClaw connection status + cached models
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);
532
+ }
533
+
534
+ ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2, availableAgents }));
535
+
536
+ // Send current connection status + cached models
464
537
  await this.ensureCachedModels();
465
538
  const openclawConnected = this.getOpenClawSocket() !== null;
466
539
  ws.send(
@@ -469,6 +542,7 @@ export class ConnectionDO implements DurableObject {
469
542
  openclawConnected,
470
543
  defaultModel: this.defaultModel,
471
544
  models: this.cachedModels,
545
+ connectedAgents: availableAgents.filter((a) => a.status === "connected"),
472
546
  }),
473
547
  );
474
548
  return;
@@ -511,6 +585,7 @@ export class ConnectionDO implements DurableObject {
511
585
  type: msg.type,
512
586
  sessionKey: msg.sessionKey,
513
587
  messageId: msg.messageId,
588
+ targetAgentId: msg.targetAgentId,
514
589
  hasMedia: !!msg.mediaUrl,
515
590
  }));
516
591
  await this.persistMessage({
@@ -520,14 +595,34 @@ export class ConnectionDO implements DurableObject {
520
595
  text: (msg.text ?? "") as string,
521
596
  mediaUrl: msg.mediaUrl as string | undefined,
522
597
  encrypted: msg.encrypted ? 1 : 0,
598
+ targetAgentId: msg.targetAgentId as string | undefined,
523
599
  });
524
600
  }
525
601
 
526
- // Forward user messages to OpenClaw
527
- const openclawWs = this.getOpenClawSocket();
528
- if (openclawWs) {
529
- // If this is a thread message, look up the parent message and attach it
530
- // 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
531
626
  const sessionKey = msg.sessionKey as string | undefined;
532
627
  const threadMatch = sessionKey?.match(/:thread:(.+)$/);
533
628
  let enrichedMsg = msg;
@@ -547,18 +642,17 @@ export class ConnectionDO implements DurableObject {
547
642
  parentSender: parentRow.sender as string,
548
643
  parentEncrypted: (parentRow.encrypted ?? 0) as number,
549
644
  };
550
- console.log(`[DO] Attached parent message for thread: parentId=${parentId}, sender=${parentRow.sender}, encrypted=${parentRow.encrypted}`);
551
645
  }
552
646
  } catch (err) {
553
- console.error(`[DO] Failed to fetch parent message for thread ${parentId}:`, err);
647
+ console.error(`[DO] Failed to fetch parent message for thread:`, err);
554
648
  }
555
649
  }
556
- openclawWs.send(JSON.stringify(enrichedMsg));
650
+ targetWs.send(JSON.stringify(enrichedMsg));
557
651
  } else {
558
652
  ws.send(
559
653
  JSON.stringify({
560
654
  type: "error",
561
- message: "OpenClaw is not connected. Please check your OpenClaw instance.",
655
+ message: "No agent is currently connected. Please check your agent instances.",
562
656
  }),
563
657
  );
564
658
  }
@@ -616,7 +710,10 @@ export class ConnectionDO implements DurableObject {
616
710
 
617
711
  private handleStatus(): Response {
618
712
  const sockets = this.state.getWebSockets();
619
- 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
+ });
620
717
  const browserCount = sockets.filter((s) =>
621
718
  this.getTag(s)?.startsWith("browser:"),
622
719
  ).length;
@@ -687,11 +784,9 @@ export class ConnectionDO implements DurableObject {
687
784
  return att?.tag ?? null;
688
785
  }
689
786
 
690
- private getOpenClawSocket(): WebSocket | null {
691
- const sockets = this.state.getWebSockets("openclaw");
692
- // Return the LAST (newest) authenticated OpenClaw socket.
693
- // After a reconnection there may briefly be multiple sockets
694
- // 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);
695
790
  let newest: WebSocket | null = null;
696
791
  for (const s of sockets) {
697
792
  const att = s.deserializeAttachment() as { authenticated: boolean } | null;
@@ -700,6 +795,147 @@ export class ConnectionDO implements DurableObject {
700
795
  return newest ?? sockets[sockets.length - 1] ?? null;
701
796
  }
702
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
+
703
939
  private broadcastToBrowsers(message: string): void {
704
940
  const sockets = this.state.getWebSockets();
705
941
  for (const s of sockets) {
@@ -1028,6 +1264,8 @@ export class ConnectionDO implements DurableObject {
1028
1264
  mediaUrl?: string;
1029
1265
  a2ui?: string;
1030
1266
  encrypted?: number;
1267
+ senderAgentId?: string;
1268
+ targetAgentId?: string;
1031
1269
  }): Promise<void> {
1032
1270
  try {
1033
1271
  const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
@@ -1042,10 +1280,10 @@ export class ConnectionDO implements DurableObject {
1042
1280
  }
1043
1281
 
1044
1282
  await this.env.DB.prepare(
1045
- `INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted)
1046
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1047
1285
  )
1048
- .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)
1049
1287
  .run();
1050
1288
  } catch (err) {
1051
1289
  console.error("Failed to persist message:", err);
@@ -1207,7 +1445,7 @@ export class ConnectionDO implements DurableObject {
1207
1445
 
1208
1446
  // Check if a matching task already exists
1209
1447
  const existingTask = await this.env.DB.prepare(
1210
- "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1448
+ "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1211
1449
  )
1212
1450
  .bind(t.cronJobId)
1213
1451
  .first<{ id: string; session_key: string }>();
@@ -1226,7 +1464,7 @@ export class ConnectionDO implements DurableObject {
1226
1464
  t.cronJobId,
1227
1465
  ];
1228
1466
  await this.env.DB.prepare(
1229
- `UPDATE tasks SET ${updateParts.join(", ")} WHERE openclaw_cron_job_id = ?`,
1467
+ `UPDATE tasks SET ${updateParts.join(", ")} WHERE provider_job_id = ?`,
1230
1468
  )
1231
1469
  .bind(...updateVals)
1232
1470
  .run();
@@ -1245,7 +1483,7 @@ export class ConnectionDO implements DurableObject {
1245
1483
  // D1 only stores basic task metadata — schedule/instructions/model
1246
1484
  // belong to OpenClaw and are delivered via task.scan.result WebSocket.
1247
1485
  await this.env.DB.prepare(
1248
- `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)
1249
1487
  VALUES (?, ?, ?, 'background', ?, ?, ?)`,
1250
1488
  )
1251
1489
  .bind(taskId, defaultChannelId, taskName, t.cronJobId, sessionKey, t.enabled ? 1 : 0)
@@ -1256,7 +1494,7 @@ export class ConnectionDO implements DurableObject {
1256
1494
 
1257
1495
  // Resolve the task record for job persistence (may have just been created)
1258
1496
  const taskRecord = await this.env.DB.prepare(
1259
- "SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
1497
+ "SELECT id, session_key FROM tasks WHERE provider_job_id = ?",
1260
1498
  )
1261
1499
  .bind(t.cronJobId)
1262
1500
  .first<{ id: string; session_key: string }>();
@@ -1320,7 +1558,7 @@ export class ConnectionDO implements DurableObject {
1320
1558
  private async ensureDefaultChannel(userId: string): Promise<string> {
1321
1559
  // Check if a default channel already exists
1322
1560
  const existing = await this.env.DB.prepare(
1323
- "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",
1324
1562
  )
1325
1563
  .bind(userId)
1326
1564
  .first<{ id: string }>();
@@ -1330,7 +1568,7 @@ export class ConnectionDO implements DurableObject {
1330
1568
  // Create the default channel
1331
1569
  const channelId = this.generateId("ch_");
1332
1570
  await this.env.DB.prepare(
1333
- "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', '')",
1334
1572
  )
1335
1573
  .bind(channelId, userId)
1336
1574
  .run();
@@ -1348,6 +1586,101 @@ export class ConnectionDO implements DurableObject {
1348
1586
  return channelId;
1349
1587
  }
1350
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
+
1351
1684
  /** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
1352
1685
  private generateId(prefix = ""): string {
1353
1686
  return generateIdUtil(prefix);
@@ -1371,7 +1704,7 @@ export class ConnectionDO implements DurableObject {
1371
1704
 
1372
1705
  // Find the task by cronJobId
1373
1706
  const task = await this.env.DB.prepare(
1374
- "SELECT id FROM tasks WHERE openclaw_cron_job_id = ?",
1707
+ "SELECT id FROM tasks WHERE provider_job_id = ?",
1375
1708
  )
1376
1709
  .bind(cronJobId)
1377
1710
  .first<{ id: string }>();