@botschat/botschat 0.1.4

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.
@@ -0,0 +1,1250 @@
1
+ import { deleteBotsChatAccount, listBotsChatAccountIds, resolveBotsChatAccount, resolveDefaultBotsChatAccountId, setBotsChatAccountEnabled, } from "./accounts.js";
2
+ import { getBotsChatRuntime } from "./runtime.js";
3
+ import { BotsChatCloudClient } from "./ws-client.js";
4
+ // ---------------------------------------------------------------------------
5
+ // A2UI message-tool hints — injected via agentPrompt.messageToolHints so
6
+ // the agent knows it can output interactive UI components. These strings
7
+ // end up inside the "message" tool documentation section of the system
8
+ // prompt, which the model pays close attention to.
9
+ // ---------------------------------------------------------------------------
10
+ const A2UI_MESSAGE_TOOL_HINTS = [
11
+ "- This channel renders ```action fenced code blocks as interactive clickable widgets. When your reply offers choices, next steps, or confirmations, you MUST wrap a single-line JSON in an ```action fence instead of using plain-text option lists.",
12
+ "- Action block format: ```action\\n{\"kind\":\"buttons\",\"prompt\":\"What next?\",\"items\":[{\"label\":\"Do X\",\"value\":\"x\",\"style\":\"primary\"},{\"label\":\"Do Y\",\"value\":\"y\"}]}\\n``` — kinds: buttons, confirm, select, input. Styles: \"primary\", \"danger\", or omit.",
13
+ "- NEVER present selectable options as plain-text lists with bullets, numbers, or emojis (✅ • - 🔧 etc.) — they are NOT clickable. Always use an ```action block for choices. Skip action blocks only for purely informational replies.",
14
+ ];
15
+ // ---------------------------------------------------------------------------
16
+ // Helper: read agent model from OpenClaw config
17
+ // ---------------------------------------------------------------------------
18
+ function readAgentModel(_agentId) {
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const fs = require("fs");
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ const path = require("path");
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const os = require("os");
26
+ const configFile = path.join(os.homedir(), ".openclaw", "openclaw.json");
27
+ if (fs.existsSync(configFile)) {
28
+ const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
29
+ const primary = cfg?.agents?.defaults?.model?.primary;
30
+ if (primary)
31
+ return primary;
32
+ }
33
+ }
34
+ catch { /* ignore */ }
35
+ return undefined;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Connection registry — maps accountId → live WSS client
39
+ // ---------------------------------------------------------------------------
40
+ const cloudClients = new Map();
41
+ /** Maps accountId → cloudUrl so handleCloudMessage can resolve relative URLs */
42
+ const cloudUrls = new Map();
43
+ function getCloudClient(accountId) {
44
+ return cloudClients.get(accountId);
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // ChannelPlugin definition
48
+ // ---------------------------------------------------------------------------
49
+ export const botschatPlugin = {
50
+ id: "botschat",
51
+ meta: {
52
+ id: "botschat",
53
+ label: "BotsChat",
54
+ selectionLabel: "BotsChat (cloud)",
55
+ docsPath: "/channels/botschat",
56
+ docsLabel: "botschat",
57
+ blurb: "Cloud-based multi-channel chat interface",
58
+ order: 80,
59
+ quickstartAllowFrom: false,
60
+ },
61
+ capabilities: {
62
+ chatTypes: ["direct", "group", "thread"],
63
+ polls: false,
64
+ reactions: false,
65
+ threads: true,
66
+ media: true,
67
+ },
68
+ agentPrompt: {
69
+ messageToolHints: () => A2UI_MESSAGE_TOOL_HINTS,
70
+ },
71
+ reload: { configPrefixes: ["channels.botschat"] },
72
+ config: {
73
+ listAccountIds: (cfg) => listBotsChatAccountIds(cfg),
74
+ resolveAccount: (cfg, accountId) => resolveBotsChatAccount(cfg, accountId),
75
+ defaultAccountId: (cfg) => resolveDefaultBotsChatAccountId(cfg),
76
+ setAccountEnabled: ({ cfg, accountId, enabled }) => setBotsChatAccountEnabled(cfg, accountId, enabled),
77
+ deleteAccount: ({ cfg, accountId }) => deleteBotsChatAccount(cfg, accountId),
78
+ isConfigured: (account) => account.configured,
79
+ isEnabled: (account) => account.enabled,
80
+ describeAccount: (account) => ({
81
+ accountId: account.accountId,
82
+ name: account.name,
83
+ enabled: account.enabled,
84
+ configured: account.configured,
85
+ baseUrl: account.cloudUrl,
86
+ }),
87
+ },
88
+ outbound: {
89
+ deliveryMode: "direct",
90
+ sendText: async (ctx) => {
91
+ const client = getCloudClient(ctx.accountId ?? "default");
92
+ if (!client?.connected) {
93
+ return { ok: false, error: new Error("Not connected to BotsChat cloud") };
94
+ }
95
+ client.send({
96
+ type: "agent.text",
97
+ sessionKey: ctx.to,
98
+ text: ctx.text,
99
+ replyToId: ctx.replyToId ?? undefined,
100
+ threadId: ctx.threadId?.toString(),
101
+ });
102
+ return { ok: true };
103
+ },
104
+ sendMedia: async (ctx) => {
105
+ const client = getCloudClient(ctx.accountId ?? "default");
106
+ if (!client?.connected) {
107
+ return { ok: false, error: new Error("Not connected to BotsChat cloud") };
108
+ }
109
+ if (ctx.mediaUrl) {
110
+ client.send({
111
+ type: "agent.media",
112
+ sessionKey: ctx.to,
113
+ mediaUrl: ctx.mediaUrl,
114
+ caption: ctx.text || undefined,
115
+ });
116
+ }
117
+ else {
118
+ client.send({
119
+ type: "agent.text",
120
+ sessionKey: ctx.to,
121
+ text: ctx.text,
122
+ });
123
+ }
124
+ return { ok: true };
125
+ },
126
+ },
127
+ gateway: {
128
+ startAccount: async (ctx) => {
129
+ const { account, accountId, log } = ctx;
130
+ if (!account.configured) {
131
+ log?.warn(`[${accountId}] BotsChat not configured — skipping`);
132
+ return;
133
+ }
134
+ ctx.setStatus({
135
+ ...ctx.getStatus(),
136
+ accountId,
137
+ baseUrl: account.cloudUrl,
138
+ running: true,
139
+ lastStartAt: Date.now(),
140
+ });
141
+ log?.info(`[${accountId}] Starting BotsChat connection to ${account.cloudUrl}`);
142
+ const client = new BotsChatCloudClient({
143
+ cloudUrl: account.cloudUrl,
144
+ accountId,
145
+ pairingToken: account.pairingToken,
146
+ getModel: () => readAgentModel("main"),
147
+ onMessage: (msg) => {
148
+ handleCloudMessage(msg, ctx);
149
+ },
150
+ onStatusChange: (connected) => {
151
+ ctx.setStatus({
152
+ ...ctx.getStatus(),
153
+ connected,
154
+ ...(connected
155
+ ? { lastConnectedAt: Date.now() }
156
+ : { lastDisconnect: { at: Date.now() } }),
157
+ });
158
+ },
159
+ log,
160
+ });
161
+ cloudClients.set(accountId, client);
162
+ cloudUrls.set(accountId, account.cloudUrl);
163
+ client.connect();
164
+ ctx.abortSignal.addEventListener("abort", () => {
165
+ client.disconnect();
166
+ cloudClients.delete(accountId);
167
+ cloudUrls.delete(accountId);
168
+ });
169
+ return client;
170
+ },
171
+ stopAccount: async (ctx) => {
172
+ const client = cloudClients.get(ctx.accountId);
173
+ if (client) {
174
+ client.disconnect();
175
+ cloudClients.delete(ctx.accountId);
176
+ }
177
+ ctx.setStatus({
178
+ ...ctx.getStatus(),
179
+ running: false,
180
+ connected: false,
181
+ lastStopAt: Date.now(),
182
+ });
183
+ },
184
+ },
185
+ threading: {
186
+ resolveReplyToMode: () => "all",
187
+ buildToolContext: ({ context, hasRepliedRef }) => ({
188
+ currentChannelId: context.To?.trim() || undefined,
189
+ currentChannelProvider: "botschat",
190
+ currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
191
+ hasRepliedRef,
192
+ }),
193
+ },
194
+ pairing: {
195
+ idLabel: "botsChatUserId",
196
+ normalizeAllowEntry: (entry) => entry.trim().toLowerCase(),
197
+ },
198
+ security: {
199
+ resolveDmPolicy: (_ctx) => ({
200
+ policy: "token",
201
+ allowFrom: [],
202
+ policyPath: "channels.botschat.pairingToken",
203
+ allowFromPath: "channels.botschat.dm.allowFrom",
204
+ approveHint: "Pair via BotsChat cloud dashboard (get a pairing token at console.botschat.app)",
205
+ }),
206
+ },
207
+ setup: {
208
+ applyAccountConfig: ({ cfg, input }) => {
209
+ const c = cfg;
210
+ return {
211
+ ...c,
212
+ channels: {
213
+ ...c?.channels,
214
+ botschat: {
215
+ ...c?.channels?.botschat,
216
+ enabled: true,
217
+ cloudUrl: input.url?.trim() ?? c?.channels?.botschat?.cloudUrl ?? "",
218
+ pairingToken: input.token?.trim() ?? c?.channels?.botschat?.pairingToken ?? "",
219
+ ...(input.name ? { name: input.name.trim() } : {}),
220
+ },
221
+ },
222
+ };
223
+ },
224
+ validateInput: ({ input }) => {
225
+ if (input.useEnv)
226
+ return null;
227
+ if (!input.url?.trim())
228
+ return "BotsChat requires --url (e.g., --url console.botschat.app)";
229
+ if (!input.token?.trim())
230
+ return "BotsChat requires --token (pairing token from console.botschat.app)";
231
+ return null;
232
+ },
233
+ },
234
+ status: {
235
+ defaultRuntime: {
236
+ accountId: "default",
237
+ running: false,
238
+ connected: false,
239
+ lastStartAt: null,
240
+ lastStopAt: null,
241
+ lastError: null,
242
+ },
243
+ buildAccountSnapshot: ({ account, runtime }) => ({
244
+ accountId: account.accountId,
245
+ name: account.name,
246
+ enabled: account.enabled,
247
+ configured: account.configured,
248
+ baseUrl: account.cloudUrl,
249
+ running: runtime?.running ?? false,
250
+ connected: runtime?.connected ?? false,
251
+ lastStartAt: runtime?.lastStartAt ?? null,
252
+ lastStopAt: runtime?.lastStopAt ?? null,
253
+ lastError: runtime?.lastError ?? null,
254
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
255
+ lastDisconnect: runtime?.lastDisconnect ?? null,
256
+ }),
257
+ collectStatusIssues: (accounts) => accounts.flatMap((a) => {
258
+ const issues = [];
259
+ if (!a.configured) {
260
+ issues.push({
261
+ channel: "botschat",
262
+ accountId: a.accountId,
263
+ kind: "config",
264
+ message: 'Not configured. Run "openclaw channel setup botschat --url <cloud-url> --token <pairing-token>"',
265
+ });
266
+ }
267
+ if (a.lastError) {
268
+ issues.push({ channel: "botschat", accountId: a.accountId, kind: "runtime", message: `Channel error: ${a.lastError}` });
269
+ }
270
+ return issues;
271
+ }),
272
+ },
273
+ };
274
+ // ---------------------------------------------------------------------------
275
+ // Incoming message handler — dispatches cloud messages into the OpenClaw
276
+ // agent pipeline via the runtime.
277
+ // ---------------------------------------------------------------------------
278
+ async function handleCloudMessage(msg, ctx) {
279
+ switch (msg.type) {
280
+ case "user.message": {
281
+ ctx.log?.info(`[${ctx.accountId}] Message from ${msg.userId}: ${msg.text.slice(0, 80)}${msg.mediaUrl ? " [+image]" : ""}`);
282
+ try {
283
+ const runtime = getBotsChatRuntime();
284
+ // Load current config
285
+ const cfg = runtime.config?.loadConfig?.() ?? ctx.cfg;
286
+ // Extract threadId from sessionKey pattern: ....:thread:{threadId}
287
+ const threadMatch = msg.sessionKey.match(/:thread:(.+)$/);
288
+ const threadId = threadMatch ? threadMatch[1] : undefined;
289
+ // Build the MsgContext that OpenClaw's dispatch pipeline expects.
290
+ // BotsChat users are authenticated (logged in via the web UI), so
291
+ // mark commands as authorized — this lets directives like /model
292
+ // pass through the command-auth pipeline instead of being silently
293
+ // dropped (the default is false / deny).
294
+ const msgCtx = {
295
+ Body: msg.text,
296
+ RawBody: msg.text,
297
+ CommandBody: msg.text,
298
+ BodyForCommands: msg.text,
299
+ From: `botschat:${msg.userId}`,
300
+ To: msg.sessionKey,
301
+ SessionKey: msg.sessionKey,
302
+ AccountId: ctx.accountId,
303
+ MessageSid: msg.messageId,
304
+ ChatType: threadId ? "thread" : "direct",
305
+ Channel: "botschat",
306
+ MessageChannel: "botschat",
307
+ Provider: "botschat",
308
+ Surface: "botschat",
309
+ CommandAuthorized: true,
310
+ // A2UI format instructions are injected via agentPrompt.messageToolHints
311
+ // (inside the message tool docs in the system prompt) — no GroupSystemPrompt needed.
312
+ ...(threadId ? { MessageThreadId: threadId, ReplyToId: threadId } : {}),
313
+ // Include image URL if the user sent an image.
314
+ // Resolve relative URLs (e.g. /api/media/...) to absolute using cloudUrl
315
+ // so OpenClaw can fetch the image from the BotsChat cloud.
316
+ ...(msg.mediaUrl ? (() => {
317
+ let resolvedUrl = msg.mediaUrl;
318
+ if (resolvedUrl.startsWith("/")) {
319
+ const baseUrl = cloudUrls.get(ctx.accountId);
320
+ if (baseUrl) {
321
+ resolvedUrl = baseUrl.replace(/\/$/, "") + resolvedUrl;
322
+ }
323
+ }
324
+ return { MediaUrl: resolvedUrl, NumMedia: "1" };
325
+ })() : {}),
326
+ };
327
+ // Finalize the context (normalizes fields, resolves agent route)
328
+ const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgCtx);
329
+ // Create a reply dispatcher that sends responses back through the cloud WSS
330
+ const client = getCloudClient(ctx.accountId);
331
+ const deliver = async (payload) => {
332
+ if (!client?.connected)
333
+ return;
334
+ if (payload.mediaUrl) {
335
+ client.send({
336
+ type: "agent.media",
337
+ sessionKey: msg.sessionKey,
338
+ mediaUrl: payload.mediaUrl,
339
+ caption: payload.text,
340
+ threadId,
341
+ });
342
+ }
343
+ else if (payload.text) {
344
+ client.send({
345
+ type: "agent.text",
346
+ sessionKey: msg.sessionKey,
347
+ text: payload.text,
348
+ threadId,
349
+ });
350
+ // Detect model-change confirmations and emit model.changed
351
+ // Handles both formats:
352
+ // "Model set to provider/model." (no parentheses)
353
+ // "Model set to Friendly Name (provider/model)." (with parentheses)
354
+ const modelMatch = payload.text.match(/Model (?:set to|reset to default)\b.*?([a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\/[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*)/);
355
+ if (modelMatch) {
356
+ client.send({
357
+ type: "model.changed",
358
+ model: modelMatch[1],
359
+ sessionKey: msg.sessionKey,
360
+ });
361
+ }
362
+ }
363
+ };
364
+ // --- Streaming support ---
365
+ // Generate a runId to correlate stream events for this reply.
366
+ const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
367
+ let streamStarted = false;
368
+ const onPartialReply = (payload) => {
369
+ if (!client?.connected || !payload.text)
370
+ return;
371
+ // Send stream start on first chunk
372
+ if (!streamStarted) {
373
+ streamStarted = true;
374
+ client.send({
375
+ type: "agent.stream.start",
376
+ sessionKey: msg.sessionKey,
377
+ runId,
378
+ });
379
+ }
380
+ // Send the accumulated text so far
381
+ client.send({
382
+ type: "agent.stream.chunk",
383
+ sessionKey: msg.sessionKey,
384
+ runId,
385
+ text: payload.text,
386
+ });
387
+ };
388
+ // Use dispatchReplyFromConfig with a simple dispatcher
389
+ const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
390
+ deliver: async (payload) => {
391
+ // The payload from the dispatcher is a ReplyPayload
392
+ const p = payload;
393
+ await deliver(p);
394
+ },
395
+ onTypingStart: () => { },
396
+ onTypingStop: () => { },
397
+ });
398
+ await runtime.channel.reply.dispatchReplyFromConfig({
399
+ ctx: finalizedCtx,
400
+ cfg,
401
+ dispatcher,
402
+ replyOptions: {
403
+ ...replyOptions,
404
+ onPartialReply,
405
+ allowPartialStream: true,
406
+ },
407
+ });
408
+ // Send stream end if streaming was active
409
+ if (streamStarted && client?.connected) {
410
+ client.send({
411
+ type: "agent.stream.end",
412
+ sessionKey: msg.sessionKey,
413
+ runId,
414
+ });
415
+ }
416
+ markDispatchIdle();
417
+ }
418
+ catch (err) {
419
+ ctx.log?.error(`[${ctx.accountId}] Failed to dispatch message: ${err}`);
420
+ }
421
+ break;
422
+ }
423
+ case "user.command":
424
+ ctx.log?.info(`[${ctx.accountId}] Command /${msg.command} in session ${msg.sessionKey}`);
425
+ // Commands are handled the same way — feed as a message with / prefix
426
+ await handleCloudMessage({
427
+ type: "user.message",
428
+ sessionKey: msg.sessionKey,
429
+ text: `/${msg.command}${msg.args ? ` ${msg.args}` : ""}`,
430
+ userId: "command",
431
+ messageId: `cmd-${Date.now()}`,
432
+ }, ctx);
433
+ break;
434
+ case "user.action":
435
+ ctx.log?.info(`[${ctx.accountId}] A2UI action ${msg.action} in session ${msg.sessionKey}`);
436
+ // Feed the user's A2UI interaction back to the agent as a message.
437
+ // This lets the agent continue the conversation based on what the
438
+ // user clicked/selected in the interactive UI component.
439
+ {
440
+ const actionParams = msg.params ?? {};
441
+ const kind = actionParams.kind ?? msg.action ?? "action";
442
+ const value = actionParams.value ?? actionParams.selected ?? "";
443
+ const label = actionParams.label ?? value;
444
+ const actionText = `[Action: kind=${kind}] User selected: "${label}"`;
445
+ await handleCloudMessage({
446
+ type: "user.message",
447
+ sessionKey: msg.sessionKey,
448
+ text: actionText,
449
+ userId: actionParams.userId ?? "action",
450
+ messageId: `action-${Date.now()}`,
451
+ }, ctx);
452
+ }
453
+ break;
454
+ case "user.media":
455
+ ctx.log?.info(`[${ctx.accountId}] Media from user in session ${msg.sessionKey}: ${msg.mediaUrl}`);
456
+ // Handle as a user.message with mediaUrl so the agent can process the image
457
+ await handleCloudMessage({
458
+ type: "user.message",
459
+ sessionKey: msg.sessionKey,
460
+ text: "",
461
+ userId: msg.userId,
462
+ messageId: `media-${Date.now()}`,
463
+ mediaUrl: msg.mediaUrl,
464
+ }, ctx);
465
+ break;
466
+ case "config.request":
467
+ ctx.log?.info(`[${ctx.accountId}] Config request: ${msg.method}`);
468
+ break;
469
+ // ---- Task management messages from BotsChat cloud ----
470
+ case "task.schedule":
471
+ ctx.log?.info(`[${ctx.accountId}] Schedule task: cronJobId=${msg.cronJobId} schedule=${msg.schedule}`);
472
+ await handleTaskSchedule(msg, ctx);
473
+ break;
474
+ case "task.delete":
475
+ ctx.log?.info(`[${ctx.accountId}] Delete task: cronJobId=${msg.cronJobId}`);
476
+ await handleTaskDelete(msg, ctx);
477
+ break;
478
+ case "task.run":
479
+ ctx.log?.info(`[${ctx.accountId}] Run task now: cronJobId=${msg.cronJobId} agentId=${msg.agentId}`);
480
+ await handleTaskRun(msg, ctx);
481
+ break;
482
+ case "task.scan.request":
483
+ ctx.log?.info(`[${ctx.accountId}] Task scan requested by cloud`);
484
+ await handleTaskScanRequest(ctx);
485
+ break;
486
+ case "models.request":
487
+ ctx.log?.info(`[${ctx.accountId}] Models list requested by cloud`);
488
+ await handleModelsRequest(ctx);
489
+ break;
490
+ default:
491
+ break;
492
+ }
493
+ }
494
+ // ---------------------------------------------------------------------------
495
+ // Task scheduling — configure CronJobs in OpenClaw via runtime
496
+ // ---------------------------------------------------------------------------
497
+ /**
498
+ * Convert a human-readable schedule string to OpenClaw's schedule object format.
499
+ * "every 30m" → { kind: "every", everyMs: 1800000 }
500
+ * "every 2h" → { kind: "every", everyMs: 7200000 }
501
+ * "every 10s" → { kind: "every", everyMs: 10000 }
502
+ * "at 09:00" → { kind: "at", at: "09:00" }
503
+ */
504
+ function parseScheduleToOpenClaw(schedule) {
505
+ if (!schedule)
506
+ return null;
507
+ // Interval: "every {N}{s|m|h}"
508
+ const everyMatch = schedule.match(/^every\s+(\d+(?:\.\d+)?)\s*(s|m|h)$/i);
509
+ if (everyMatch) {
510
+ const value = parseFloat(everyMatch[1]);
511
+ const unit = everyMatch[2].toLowerCase();
512
+ let everyMs;
513
+ if (unit === "s")
514
+ everyMs = value * 1000;
515
+ else if (unit === "m")
516
+ everyMs = value * 60000;
517
+ else
518
+ everyMs = value * 3600000; // h
519
+ return { kind: "every", everyMs };
520
+ }
521
+ // Daily: "at HH:MM"
522
+ const atMatch = schedule.match(/^at\s+(\d{1,2}:\d{2})$/i);
523
+ if (atMatch) {
524
+ return { kind: "at", at: atMatch[1] };
525
+ }
526
+ return null;
527
+ }
528
+ /**
529
+ * Run `openclaw cron edit` to hot-update the CronService.
530
+ * This uses the gateway's RPC (via CLI) so the in-memory scheduler is updated
531
+ * immediately — no gateway restart needed.
532
+ */
533
+ async function openclawCronEdit(cronJobId, args, log) {
534
+ const { execFile } = await import("child_process");
535
+ const { promisify } = await import("util");
536
+ const execFileAsync = promisify(execFile);
537
+ const fullArgs = ["cron", "edit", cronJobId, ...args];
538
+ log?.info(`Running: openclaw ${fullArgs.join(" ")}`);
539
+ try {
540
+ const { stdout, stderr } = await execFileAsync("openclaw", fullArgs, {
541
+ timeout: 15_000,
542
+ env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
543
+ });
544
+ if (stderr?.trim())
545
+ log?.warn(`openclaw cron edit stderr: ${stderr.trim()}`);
546
+ if (stdout?.trim())
547
+ log?.info(`openclaw cron edit: ${stdout.trim()}`);
548
+ return { ok: true };
549
+ }
550
+ catch (err) {
551
+ const message = err instanceof Error ? err.message : String(err);
552
+ log?.error(`openclaw cron edit failed: ${message}`);
553
+ return { ok: false, error: message };
554
+ }
555
+ }
556
+ /**
557
+ * Run `openclaw cron add` to create a new cron job.
558
+ * Returns the OpenClaw-generated job ID.
559
+ */
560
+ async function openclawCronAdd(msg, log) {
561
+ const { execFile } = await import("child_process");
562
+ const { promisify } = await import("util");
563
+ const execFileAsync = promisify(execFile);
564
+ const args = ["cron", "add"];
565
+ // Name (required by openclaw cron add)
566
+ args.push("--name", msg.name || "BotsChat Task");
567
+ // Schedule
568
+ const s = (msg.schedule || "").trim();
569
+ if (/^at\s+/i.test(s)) {
570
+ args.push("--at", s.replace(/^at\s+/i, ""));
571
+ }
572
+ else if (s) {
573
+ args.push("--every", s.replace(/^every\s+/i, ""));
574
+ }
575
+ // Payload
576
+ args.push("--message", msg.instructions || "Run your scheduled task.");
577
+ args.push("--session", "isolated");
578
+ if (msg.agentId)
579
+ args.push("--agent", msg.agentId);
580
+ if (msg.model)
581
+ args.push("--model", msg.model);
582
+ if (!msg.enabled)
583
+ args.push("--disabled");
584
+ args.push("--json");
585
+ log?.info(`Running: openclaw ${args.join(" ")}`);
586
+ try {
587
+ const { stdout } = await execFileAsync("openclaw", args, {
588
+ timeout: 15_000,
589
+ env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
590
+ });
591
+ // Parse the JSON output to get the generated ID.
592
+ // stdout may contain Config warnings before the JSON — extract
593
+ // the last {...} block.
594
+ const jsonMatch = stdout.match(/\{[\s\S]*\}/);
595
+ if (!jsonMatch) {
596
+ return { ok: false, error: `openclaw cron add: no JSON in output: ${stdout.slice(0, 200)}` };
597
+ }
598
+ const result = JSON.parse(jsonMatch[0]);
599
+ const cronJobId = result.id;
600
+ if (!cronJobId) {
601
+ return { ok: false, error: "openclaw cron add returned no id" };
602
+ }
603
+ return { ok: true, cronJobId };
604
+ }
605
+ catch (err) {
606
+ const message = err instanceof Error ? err.message : String(err);
607
+ log?.error(`openclaw cron add failed: ${message}`);
608
+ return { ok: false, error: message };
609
+ }
610
+ }
611
+ /**
612
+ * Check if a cron job exists in OpenClaw by reading jobs.json.
613
+ */
614
+ async function cronJobExists(cronJobId) {
615
+ try {
616
+ const os = await import("os");
617
+ const fs = await import("fs");
618
+ const path = await import("path");
619
+ const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
620
+ if (!fs.existsSync(cronFile))
621
+ return false;
622
+ const data = JSON.parse(fs.readFileSync(cronFile, "utf-8"));
623
+ return Array.isArray(data.jobs) && data.jobs.some((j) => j.id === cronJobId);
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
629
+ async function handleTaskSchedule(msg, ctx) {
630
+ const client = getCloudClient(ctx.accountId);
631
+ try {
632
+ const exists = msg.cronJobId ? await cronJobExists(msg.cronJobId) : false;
633
+ if (exists) {
634
+ // Update existing job via `openclaw cron edit` (hot-updates CronService)
635
+ const args = [];
636
+ if (msg.schedule) {
637
+ const s = msg.schedule.trim();
638
+ if (/^at\s+/i.test(s)) {
639
+ args.push("--at", s.replace(/^at\s+/i, ""));
640
+ }
641
+ else {
642
+ args.push("--every", s.replace(/^every\s+/i, ""));
643
+ }
644
+ }
645
+ // Always send --message to ensure payload.kind="agentTurn" is set
646
+ // (required for isolated session jobs). If no new instructions, read
647
+ // the existing ones from jobs.json.
648
+ const messageText = msg.instructions || (await readCronJobConfig(msg.cronJobId)).instructions || "Run your scheduled task.";
649
+ args.push("--message", messageText);
650
+ if (msg.model)
651
+ args.push("--model", msg.model);
652
+ if (msg.enabled)
653
+ args.push("--enable");
654
+ else
655
+ args.push("--disable");
656
+ const result = await openclawCronEdit(msg.cronJobId, args, ctx.log);
657
+ if (!result.ok) {
658
+ client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: result.error });
659
+ return;
660
+ }
661
+ ctx.log?.info(`[${ctx.accountId}] Updated cron job ${msg.cronJobId}: ${msg.schedule}`);
662
+ client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: true });
663
+ }
664
+ else {
665
+ // New job: use `openclaw cron add --json` (hot-adds to CronService)
666
+ ctx.log?.info(`[${ctx.accountId}] Creating new cron job via openclaw cron add`);
667
+ const addResult = await openclawCronAdd(msg, ctx.log);
668
+ if (!addResult.ok) {
669
+ client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: addResult.error });
670
+ return;
671
+ }
672
+ // Return the OpenClaw-generated ID + taskId so DO can update D1
673
+ ctx.log?.info(`[${ctx.accountId}] Created cron job ${addResult.cronJobId}: ${msg.schedule}`);
674
+ client?.send({ type: "task.schedule.ack", cronJobId: addResult.cronJobId, taskId: msg.taskId, ok: true });
675
+ }
676
+ }
677
+ catch (err) {
678
+ ctx.log?.error(`[${ctx.accountId}] Failed to schedule task: ${err}`);
679
+ client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: String(err) });
680
+ }
681
+ }
682
+ async function handleTaskDelete(msg, ctx) {
683
+ try {
684
+ const { execFile } = await import("child_process");
685
+ const { promisify } = await import("util");
686
+ const execFileAsync = promisify(execFile);
687
+ ctx.log?.info(`[${ctx.accountId}] Removing cron job ${msg.cronJobId} via openclaw cron rm`);
688
+ await execFileAsync("openclaw", ["cron", "rm", msg.cronJobId], {
689
+ timeout: 15_000,
690
+ env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
691
+ });
692
+ ctx.log?.info(`[${ctx.accountId}] Removed cron job ${msg.cronJobId}`);
693
+ }
694
+ catch (err) {
695
+ ctx.log?.error(`[${ctx.accountId}] Failed to delete task: ${err}`);
696
+ }
697
+ }
698
+ // ---------------------------------------------------------------------------
699
+ // task.run — execute a cron job immediately on demand
700
+ // ---------------------------------------------------------------------------
701
+ /**
702
+ * Read instructions and model for a cron job from OpenClaw's jobs.json (the single source of truth).
703
+ */
704
+ async function readCronJobConfig(cronJobId) {
705
+ try {
706
+ const os = await import("os");
707
+ const fs = await import("fs");
708
+ const path = await import("path");
709
+ const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
710
+ if (fs.existsSync(cronFile)) {
711
+ const data = JSON.parse(fs.readFileSync(cronFile, "utf-8"));
712
+ const job = (data.jobs ?? []).find((j) => j.id === cronJobId);
713
+ if (job) {
714
+ let instructions = "";
715
+ if (typeof job.payload === "string") {
716
+ instructions = job.payload;
717
+ }
718
+ else if (job.payload && typeof job.payload === "object") {
719
+ instructions = job.payload.message ?? job.payload.text ?? job.payload.prompt ?? "";
720
+ }
721
+ return { instructions, model: job.model };
722
+ }
723
+ }
724
+ }
725
+ catch { /* ignore */ }
726
+ return { instructions: "" };
727
+ }
728
+ async function handleTaskRun(msg, ctx) {
729
+ const client = getCloudClient(ctx.accountId);
730
+ if (!client?.connected) {
731
+ ctx.log?.error(`[${ctx.accountId}] Cannot run task — not connected`);
732
+ return;
733
+ }
734
+ const now = Date.now();
735
+ const jobId = `job_run_${msg.cronJobId}_${now}`;
736
+ const agentId = msg.agentId || "main";
737
+ // Use a unique sessionKey per run so each cron execution starts with a
738
+ // fresh context. Previously all runs shared a single session, which
739
+ // caused the context to grow unboundedly (browser screenshots, tool
740
+ // results, etc.) until the model provider rejected the request body
741
+ // (HTTP 422 "Unsupported request body").
742
+ const sessionKey = `agent:${agentId}:cron:${msg.cronJobId}:run:${now}`;
743
+ const startedAt = Math.floor(now / 1000);
744
+ // Immediately send "running" status
745
+ client.send({
746
+ type: "job.update",
747
+ cronJobId: msg.cronJobId,
748
+ jobId,
749
+ sessionKey,
750
+ status: "running",
751
+ startedAt,
752
+ });
753
+ ctx.log?.info(`[${ctx.accountId}] Task ${msg.cronJobId} started (jobId=${jobId})`);
754
+ let summary = "";
755
+ let status = "ok";
756
+ try {
757
+ const runtime = getBotsChatRuntime();
758
+ // First try: use runtime.cron.runJobNow if available
759
+ if (runtime.cron?.runJobNow) {
760
+ ctx.log?.info(`[${ctx.accountId}] Using runtime.cron.runJobNow`);
761
+ await runtime.cron.runJobNow(msg.cronJobId);
762
+ // Read the output from session file
763
+ summary = await readLastSessionOutput(agentId, msg.cronJobId, ctx);
764
+ }
765
+ else if (runtime.cron?.triggerJob) {
766
+ ctx.log?.info(`[${ctx.accountId}] Using runtime.cron.triggerJob`);
767
+ await runtime.cron.triggerJob(msg.cronJobId);
768
+ summary = await readLastSessionOutput(agentId, msg.cronJobId, ctx);
769
+ }
770
+ else {
771
+ // Fallback: dispatch the instructions as a user message through the agent pipeline
772
+ ctx.log?.info(`[${ctx.accountId}] Fallback: dispatching instructions via agent pipeline`);
773
+ // Read instructions from OpenClaw's jobs.json (single source of truth),
774
+ // falling back to msg.instructions for backward compatibility.
775
+ const jobConfig = await readCronJobConfig(msg.cronJobId);
776
+ const instructions = jobConfig.instructions || msg.instructions || "Run your scheduled task now.";
777
+ const cfg = runtime.config?.loadConfig?.() ?? ctx.cfg;
778
+ const msgCtx = {
779
+ Body: instructions,
780
+ RawBody: instructions,
781
+ CommandBody: instructions,
782
+ BodyForCommands: instructions,
783
+ From: `botschat:cron:${msg.cronJobId}`,
784
+ To: sessionKey,
785
+ SessionKey: sessionKey,
786
+ AccountId: ctx.accountId,
787
+ MessageSid: `cron-run-${Date.now()}`,
788
+ ChatType: "direct",
789
+ Channel: "botschat",
790
+ MessageChannel: "botschat",
791
+ CommandAuthorized: true,
792
+ };
793
+ const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgCtx);
794
+ // Collect the agent's reply as summary + stream output in real-time
795
+ // We accumulate completed message blocks and current streaming text.
796
+ // The frontend receives the full accumulated text each time and renders
797
+ // each block (separated by \n\n---\n\n) as a stacked message card.
798
+ const completedParts = [];
799
+ let currentStreamText = "";
800
+ let sendTimer = null;
801
+ const THROTTLE_MS = 200;
802
+ const getFullText = () => {
803
+ const parts = [...completedParts];
804
+ if (currentStreamText)
805
+ parts.push(currentStreamText);
806
+ return parts.join("\n\n---\n\n");
807
+ };
808
+ const sendOutput = () => {
809
+ if (!client?.connected)
810
+ return;
811
+ client.send({
812
+ type: "job.output",
813
+ cronJobId: msg.cronJobId,
814
+ jobId,
815
+ text: getFullText(),
816
+ });
817
+ };
818
+ const throttledSendOutput = () => {
819
+ if (sendTimer)
820
+ return; // already scheduled
821
+ sendTimer = setTimeout(() => {
822
+ sendTimer = null;
823
+ sendOutput();
824
+ }, THROTTLE_MS);
825
+ };
826
+ const deliver = async (payload) => {
827
+ if (payload.text) {
828
+ completedParts.push(payload.text);
829
+ currentStreamText = "";
830
+ // Flush immediately on completed message
831
+ if (sendTimer) {
832
+ clearTimeout(sendTimer);
833
+ sendTimer = null;
834
+ }
835
+ sendOutput();
836
+ }
837
+ };
838
+ // Stream partial output in real-time via job.output (throttled)
839
+ const onPartialReply = (payload) => {
840
+ if (!client?.connected || !payload.text)
841
+ return;
842
+ currentStreamText = payload.text;
843
+ throttledSendOutput();
844
+ };
845
+ const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
846
+ deliver: async (payload) => {
847
+ const p = payload;
848
+ await deliver(p);
849
+ },
850
+ onTypingStart: () => { },
851
+ onTypingStop: () => { },
852
+ });
853
+ await runtime.channel.reply.dispatchReplyFromConfig({
854
+ ctx: finalizedCtx,
855
+ cfg,
856
+ dispatcher,
857
+ replyOptions: {
858
+ ...replyOptions,
859
+ onPartialReply,
860
+ allowPartialStream: true,
861
+ },
862
+ });
863
+ markDispatchIdle();
864
+ // Flush any pending throttled output
865
+ if (sendTimer) {
866
+ clearTimeout(sendTimer);
867
+ sendTimer = null;
868
+ }
869
+ summary = completedParts.join("\n\n---\n\n");
870
+ }
871
+ }
872
+ catch (err) {
873
+ status = "error";
874
+ summary = `Task failed: ${String(err)}`;
875
+ ctx.log?.error(`[${ctx.accountId}] Task ${msg.cronJobId} failed: ${err}`);
876
+ }
877
+ const finishedAt = Math.floor(Date.now() / 1000);
878
+ const durationMs = (finishedAt - startedAt) * 1000;
879
+ // Send final status
880
+ client.send({
881
+ type: "job.update",
882
+ cronJobId: msg.cronJobId,
883
+ jobId,
884
+ sessionKey,
885
+ status,
886
+ summary,
887
+ startedAt,
888
+ finishedAt,
889
+ durationMs,
890
+ });
891
+ ctx.log?.info(`[${ctx.accountId}] Task ${msg.cronJobId} finished: status=${status} duration=${durationMs}ms`);
892
+ }
893
+ /**
894
+ * Layer 1 — Read cron run log entries directly from
895
+ * ~/.openclaw/cron/runs/{jobId}.jsonl (most recent last).
896
+ */
897
+ async function readCronRunLog(jobId, limit = 5) {
898
+ try {
899
+ const os = await import("os");
900
+ const fs = await import("fs");
901
+ const path = await import("path");
902
+ const logFile = path.join(os.homedir(), ".openclaw", "cron", "runs", `${jobId}.jsonl`);
903
+ if (!fs.existsSync(logFile))
904
+ return [];
905
+ const stat = fs.statSync(logFile);
906
+ const readSize = Math.min(stat.size, 32768);
907
+ const buf = Buffer.alloc(readSize);
908
+ const fd = fs.openSync(logFile, "r");
909
+ fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
910
+ fs.closeSync(fd);
911
+ const tail = buf.toString("utf-8");
912
+ const lines = tail.split("\n").filter(Boolean);
913
+ const entries = [];
914
+ for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
915
+ try {
916
+ const obj = JSON.parse(lines[i]);
917
+ if (obj?.action === "finished" && obj.jobId === jobId) {
918
+ entries.push(obj);
919
+ }
920
+ }
921
+ catch { /* skip malformed line */ }
922
+ }
923
+ return entries.reverse(); // chronological order
924
+ }
925
+ catch {
926
+ return [];
927
+ }
928
+ }
929
+ /**
930
+ * Layer 2 — CLI fallback: `openclaw cron runs --id <jobId> --limit <n>`.
931
+ * Uses the Gateway RPC under the hood, returns the same data as Layer 1.
932
+ */
933
+ async function readCronRunLogViaCli(jobId, limit = 5, log) {
934
+ try {
935
+ const { execFile } = await import("child_process");
936
+ const { promisify } = await import("util");
937
+ const execFileAsync = promisify(execFile);
938
+ const { stdout } = await execFileAsync("openclaw", ["cron", "runs", "--id", jobId, "--limit", String(limit)], {
939
+ timeout: 15_000,
940
+ env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
941
+ });
942
+ const result = JSON.parse(stdout.trim());
943
+ return (result?.entries ?? []);
944
+ }
945
+ catch (err) {
946
+ log?.warn?.(`CLI openclaw cron runs failed for ${jobId}: ${err}`);
947
+ return [];
948
+ }
949
+ }
950
+ /**
951
+ * Layer 3 — Read assistant output from a session JSONL file by sessionId.
952
+ * Returns the last assistant text and model used.
953
+ */
954
+ async function readSessionOutputById(agentId, sessionId) {
955
+ try {
956
+ const os = await import("os");
957
+ const fs = await import("fs");
958
+ const path = await import("path");
959
+ const jsonlFile = path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`);
960
+ if (!fs.existsSync(jsonlFile))
961
+ return { text: "" };
962
+ const stat = fs.statSync(jsonlFile);
963
+ const readSize = Math.min(stat.size, 16384);
964
+ const buf = Buffer.alloc(readSize);
965
+ const fd = fs.openSync(jsonlFile, "r");
966
+ fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
967
+ fs.closeSync(fd);
968
+ const tail = buf.toString("utf-8");
969
+ const lines = tail.split("\n").filter(Boolean);
970
+ let text = "";
971
+ let model;
972
+ for (let i = lines.length - 1; i >= 0; i--) {
973
+ try {
974
+ const entry = JSON.parse(lines[i]);
975
+ if (entry?.message?.role === "assistant") {
976
+ if (!model && entry.message.model) {
977
+ model = entry.message.model;
978
+ }
979
+ if (!text && Array.isArray(entry.message.content)) {
980
+ const textPart = entry.message.content.find((c) => c.type === "text" && c.text);
981
+ if (textPart)
982
+ text = textPart.text;
983
+ }
984
+ if (text && model)
985
+ break;
986
+ }
987
+ }
988
+ catch { /* skip */ }
989
+ }
990
+ return { text, model };
991
+ }
992
+ catch {
993
+ return { text: "" };
994
+ }
995
+ }
996
+ /**
997
+ * Read the last cron output using 3-layer strategy:
998
+ * 1. Run log JSONL (~/.openclaw/cron/runs/{jobId}.jsonl) → summary
999
+ * 2. CLI fallback (openclaw cron runs) → same data
1000
+ * 3. Session JSONL (~/.openclaw/agents/.../sessions/{sessionId}.jsonl) → full output
1001
+ */
1002
+ async function readLastSessionOutput(agentId, cronJobId, ctx) {
1003
+ // Layer 1: read run log directly
1004
+ let entries = await readCronRunLog(cronJobId, 1);
1005
+ // Layer 2: CLI fallback
1006
+ if (entries.length === 0) {
1007
+ ctx.log?.info?.(`Run log empty for ${cronJobId}, trying CLI fallback`);
1008
+ entries = await readCronRunLogViaCli(cronJobId, 1, ctx.log);
1009
+ }
1010
+ const lastEntry = entries.length > 0 ? entries[entries.length - 1] : undefined;
1011
+ // If run log has a summary, use it
1012
+ if (lastEntry?.summary) {
1013
+ return lastEntry.summary;
1014
+ }
1015
+ // Layer 3: read session JSONL for full output
1016
+ const sessionId = lastEntry?.sessionId;
1017
+ if (sessionId) {
1018
+ const result = await readSessionOutputById(agentId, sessionId);
1019
+ if (result.text)
1020
+ return result.text;
1021
+ }
1022
+ // Final fallback: try sessions.json lookup (original approach)
1023
+ try {
1024
+ const os = await import("os");
1025
+ const fs = await import("fs");
1026
+ const path = await import("path");
1027
+ const sessionsFile = path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
1028
+ if (!fs.existsSync(sessionsFile))
1029
+ return "";
1030
+ const sessData = JSON.parse(fs.readFileSync(sessionsFile, "utf-8"));
1031
+ const sessKey = `agent:${agentId}:cron:${cronJobId}`;
1032
+ const sessEntry = sessData[sessKey];
1033
+ if (!sessEntry?.sessionId)
1034
+ return "";
1035
+ const result = await readSessionOutputById(agentId, sessEntry.sessionId);
1036
+ return result.text;
1037
+ }
1038
+ catch (err) {
1039
+ ctx.log?.warn?.(`Failed to read session output: ${err}`);
1040
+ }
1041
+ return "";
1042
+ }
1043
+ // ---------------------------------------------------------------------------
1044
+ // Startup task scanning — scan existing CronJobs and report to cloud
1045
+ // ---------------------------------------------------------------------------
1046
+ // ---------------------------------------------------------------------------
1047
+ // Models listing — read configured providers from OpenClaw config.
1048
+ // Extracts unique provider names from model keys (provider/model format)
1049
+ // so the dropdown matches what `/models` returns.
1050
+ // ---------------------------------------------------------------------------
1051
+ async function handleModelsRequest(ctx) {
1052
+ const client = getCloudClient(ctx.accountId);
1053
+ if (!client?.connected)
1054
+ return;
1055
+ try {
1056
+ const os = await import("os");
1057
+ const fs = await import("fs");
1058
+ const path = await import("path");
1059
+ const configFile = path.join(os.homedir(), ".openclaw", "openclaw.json");
1060
+ // Collect all model keys, then group by provider
1061
+ const allKeys = [];
1062
+ const addKey = (raw) => {
1063
+ const trimmed = raw.trim();
1064
+ if (trimmed)
1065
+ allKeys.push(trimmed);
1066
+ };
1067
+ if (fs.existsSync(configFile)) {
1068
+ const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
1069
+ // 1. Primary default model
1070
+ const primary = cfg?.agents?.defaults?.model?.primary;
1071
+ if (typeof primary === "string")
1072
+ addKey(primary);
1073
+ // 2. Fallback models
1074
+ const fallbacks = cfg?.agents?.defaults?.model?.fallbacks;
1075
+ if (Array.isArray(fallbacks)) {
1076
+ for (const fb of fallbacks) {
1077
+ if (typeof fb === "string")
1078
+ addKey(fb);
1079
+ }
1080
+ }
1081
+ // 3. Configured models (allowlist)
1082
+ const configuredModels = cfg?.agents?.defaults?.models;
1083
+ if (configuredModels && typeof configuredModels === "object") {
1084
+ for (const key of Object.keys(configuredModels)) {
1085
+ addKey(key);
1086
+ }
1087
+ }
1088
+ // 4. Image model + fallbacks
1089
+ const imagePrimary = cfg?.agents?.defaults?.imageModel?.primary;
1090
+ if (typeof imagePrimary === "string")
1091
+ addKey(imagePrimary);
1092
+ const imageFallbacks = cfg?.agents?.defaults?.imageModel?.fallbacks;
1093
+ if (Array.isArray(imageFallbacks)) {
1094
+ for (const fb of imageFallbacks) {
1095
+ if (typeof fb === "string")
1096
+ addKey(fb);
1097
+ }
1098
+ }
1099
+ }
1100
+ // Deduplicate full model keys (provider/model)
1101
+ const seen = new Set();
1102
+ const models = [];
1103
+ for (const key of allKeys) {
1104
+ if (seen.has(key))
1105
+ continue;
1106
+ seen.add(key);
1107
+ const slash = key.indexOf("/");
1108
+ const provider = slash > 0 ? key.slice(0, slash) : key;
1109
+ const model = slash > 0 ? key.slice(slash + 1) : key;
1110
+ models.push({ id: key, name: model, provider });
1111
+ }
1112
+ ctx.log?.info(`[${ctx.accountId}] Models scan: found ${models.length} providers`);
1113
+ client.send({ type: "models.list", models });
1114
+ }
1115
+ catch (err) {
1116
+ ctx.log?.error(`[${ctx.accountId}] Failed to read models: ${err}`);
1117
+ client.send({ type: "models.list", models: [] });
1118
+ }
1119
+ }
1120
+ // ---------------------------------------------------------------------------
1121
+ // Startup task scanning — scan existing CronJobs and report to cloud
1122
+ // ---------------------------------------------------------------------------
1123
+ async function handleTaskScanRequest(ctx) {
1124
+ const client = getCloudClient(ctx.accountId);
1125
+ if (!client?.connected)
1126
+ return;
1127
+ try {
1128
+ const scannedTasks = [];
1129
+ // Read cron jobs directly from ~/.openclaw/cron/jobs.json
1130
+ // because runtime.cron is not exposed to plugins.
1131
+ const os = await import("os");
1132
+ const fs = await import("fs");
1133
+ const path = await import("path");
1134
+ const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
1135
+ if (fs.existsSync(cronFile)) {
1136
+ const raw = fs.readFileSync(cronFile, "utf-8");
1137
+ const data = JSON.parse(raw);
1138
+ if (Array.isArray(data.jobs)) {
1139
+ for (const job of data.jobs) {
1140
+ // Convert schedule object to a human-readable string
1141
+ let scheduleStr = "";
1142
+ if (job.schedule) {
1143
+ if (job.schedule.kind === "every" && job.schedule.everyMs) {
1144
+ const ms = job.schedule.everyMs;
1145
+ if (ms >= 3600000)
1146
+ scheduleStr = `every ${ms / 3600000}h`;
1147
+ else if (ms >= 60000)
1148
+ scheduleStr = `every ${ms / 60000}m`;
1149
+ else
1150
+ scheduleStr = `every ${ms / 1000}s`;
1151
+ }
1152
+ else if (job.schedule.kind === "at" && job.schedule.at) {
1153
+ scheduleStr = `at ${job.schedule.at}`;
1154
+ }
1155
+ }
1156
+ let lastRun;
1157
+ // Prefer model from jobs.json (explicitly set by user), fallback to session detection
1158
+ let detectedModel = job.model ?? "";
1159
+ if (job.state?.lastRunAtMs) {
1160
+ // 3-layer strategy to get last run output:
1161
+ // Layer 1: run log JSONL → summary
1162
+ // Layer 2: CLI fallback → same data
1163
+ // Layer 3: session JSONL → full assistant output
1164
+ let lastOutput = "";
1165
+ const agentId = job.agentId ?? "main";
1166
+ // Layer 1: read run log directly
1167
+ let runEntries = await readCronRunLog(job.id, 1);
1168
+ // Layer 2: CLI fallback if run log file not found
1169
+ if (runEntries.length === 0) {
1170
+ runEntries = await readCronRunLogViaCli(job.id, 1, ctx.log);
1171
+ }
1172
+ const lastRunEntry = runEntries.length > 0 ? runEntries[runEntries.length - 1] : undefined;
1173
+ if (lastRunEntry?.summary) {
1174
+ lastOutput = lastRunEntry.summary;
1175
+ }
1176
+ // Layer 3: if summary is empty, read session JSONL for full output
1177
+ if (!lastOutput) {
1178
+ const sessionId = lastRunEntry?.sessionId;
1179
+ if (sessionId) {
1180
+ // Use sessionId from run log — no need to look up sessions.json
1181
+ const sessResult = await readSessionOutputById(agentId, sessionId);
1182
+ if (sessResult.text)
1183
+ lastOutput = sessResult.text;
1184
+ if (!detectedModel && sessResult.model)
1185
+ detectedModel = sessResult.model;
1186
+ }
1187
+ }
1188
+ // Use durationMs from run log if available (more accurate)
1189
+ const durationMs = lastRunEntry?.durationMs ?? job.state.lastDurationMs;
1190
+ const status = lastRunEntry?.status ?? job.state.lastStatus ?? "ok";
1191
+ const ts = lastRunEntry?.runAtMs
1192
+ ? Math.floor(lastRunEntry.runAtMs / 1000)
1193
+ : Math.floor(job.state.lastRunAtMs / 1000);
1194
+ lastRun = {
1195
+ status,
1196
+ ts,
1197
+ summary: lastOutput || undefined,
1198
+ durationMs,
1199
+ };
1200
+ }
1201
+ // Extract instructions/prompt from payload
1202
+ let instructions = "";
1203
+ if (job.payload) {
1204
+ if (typeof job.payload === "string") {
1205
+ instructions = job.payload;
1206
+ }
1207
+ else if (typeof job.payload === "object" && job.payload !== null) {
1208
+ const p = job.payload;
1209
+ instructions = p.message ?? p.text ?? p.prompt ?? "";
1210
+ }
1211
+ }
1212
+ // Also try to get model from agent config if not found in session
1213
+ if (!detectedModel) {
1214
+ try {
1215
+ const agentConfigFile = path.join(os.homedir(), ".openclaw", "agents", job.agentId ?? "main", "config.json");
1216
+ if (fs.existsSync(agentConfigFile)) {
1217
+ const agentCfg = JSON.parse(fs.readFileSync(agentConfigFile, "utf-8"));
1218
+ if (agentCfg?.model)
1219
+ detectedModel = agentCfg.model;
1220
+ }
1221
+ }
1222
+ catch { /* ignore */ }
1223
+ }
1224
+ scannedTasks.push({
1225
+ cronJobId: job.id,
1226
+ name: job.name ?? job.id,
1227
+ schedule: scheduleStr,
1228
+ agentId: job.agentId ?? "",
1229
+ enabled: job.enabled !== false,
1230
+ instructions,
1231
+ model: detectedModel || undefined,
1232
+ lastRun,
1233
+ });
1234
+ }
1235
+ }
1236
+ ctx.log?.info(`[${ctx.accountId}] Task scan: read ${scannedTasks.length} jobs from ${cronFile}`);
1237
+ }
1238
+ else {
1239
+ ctx.log?.info(`[${ctx.accountId}] Task scan: cron file not found at ${cronFile}`);
1240
+ }
1241
+ ctx.log?.info(`[${ctx.accountId}] Task scan complete: found ${scannedTasks.length} background tasks`);
1242
+ client.send({ type: "task.scan.result", tasks: scannedTasks });
1243
+ }
1244
+ catch (err) {
1245
+ ctx.log?.error(`[${ctx.accountId}] Task scan failed: ${err}`);
1246
+ // Send empty result on error
1247
+ client.send({ type: "task.scan.result", tasks: [] });
1248
+ }
1249
+ }
1250
+ //# sourceMappingURL=channel.js.map