diff --git a/CHANGELOG.md b/CHANGELOG.md index 80aa198371..ecc6df6a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn). - Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs. +- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config. - Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07. - Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman. - Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index e45666951b..58ff8b14bf 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -23,7 +23,7 @@ cron is the mechanism. - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, optionally deliver output. + - **Isolated**: run a dedicated agent turn in `cron:`, with a delivery mode (legacy summary, announce, full output, or none). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. ## Quick start (actionable) @@ -53,7 +53,7 @@ openclaw cron add \ --tz "America/Los_Angeles" \ --session isolated \ --message "Summarize overnight updates." \ - --deliver \ + --announce \ --channel slack \ --to "channel:C1234567890" ``` @@ -96,7 +96,7 @@ A cron job is a stored record with: - a **schedule** (when it should run), - a **payload** (what it should do), -- optional **delivery** (where output should be sent). +- optional **delivery mode** (announce, full output, or none). - optional **agent binding** (`agentId`): run the job under a specific agent; if missing or unknown, the gateway falls back to the default agent. @@ -136,9 +136,12 @@ Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. - Each run starts a **fresh session id** (no prior conversation carry-over). -- A summary is posted to the main session (prefix `Cron`, configurable). -- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary. -- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal. +- Legacy behavior (no `delivery` field): a summary is posted to the main session (prefix `Cron`, configurable). +- `delivery.mode` (isolated-only) chooses what happens instead of the legacy summary: + - `announce`: subagent-style summary delivered immediately to a chat. + - `deliver`: full agent output delivered immediately to a chat. + - `none`: internal only (no main summary, no delivery). +- `wakeMode: "now"` triggers an immediate heartbeat after posting the **legacy** summary. Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam your main chat history. @@ -155,10 +158,20 @@ Common `agentTurn` fields: - `message`: required text prompt. - `model` / `thinking`: optional overrides (see below). - `timeoutSeconds`: optional timeout override. -- `deliver`: `true` to send output to a channel target. -- `channel`: `last` or a specific channel. -- `to`: channel-specific target (phone/chat/channel id). -- `bestEffortDeliver`: avoid failing the job if delivery fails. + +Delivery config (isolated jobs only): + +- `delivery.mode`: `none` | `announce` | `deliver`. +- `delivery.channel`: `last` or a specific channel. +- `delivery.to`: channel-specific target (phone/chat/channel id). +- `delivery.bestEffort`: avoid failing the job if delivery fails (deliver mode). + +Legacy delivery fields (still accepted when `delivery` is omitted): + +- `payload.deliver`: `true` to send output to a channel target. +- `payload.channel`: `last` or a specific channel. +- `payload.to`: channel-specific target (phone/chat/channel id). +- `payload.bestEffortDeliver`: avoid failing the job if delivery fails. Isolation options (only for `session=isolated`): @@ -166,6 +179,8 @@ Isolation options (only for `session=isolated`): - `postToMainMode`: `summary` (default) or `full`. - `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000). +Note: isolation post-to-main settings apply to legacy jobs (no `delivery` field). If `delivery` is set, the legacy summary is skipped. + ### Model and thinking overrides Isolated jobs (`agentTurn`) can override the model and thinking level: @@ -185,19 +200,24 @@ Resolution priority: ### Delivery (channel + target) -Isolated jobs can deliver output to a channel. The job payload can specify: +Isolated jobs can deliver output to a channel via the top-level `delivery` config: -- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last` -- `to`: channel-specific recipient target +- `delivery.mode`: `announce` (subagent-style summary) or `deliver` (full output). +- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`. +- `delivery.to`: channel-specific recipient target. -If `channel` or `to` is omitted, cron can fall back to the main session’s “last route” -(the last place the agent replied). +Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`). -Delivery notes: +If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s +“last route” (the last place the agent replied). -- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted. -- Use `deliver: true` when you want last-route delivery without an explicit `to`. -- Use `deliver: false` to keep output internal even if a `to` is present. +Legacy behavior (no `delivery` field): + +- If `payload.to` is set, cron auto-delivers the agent’s final output even if `payload.deliver` is omitted. +- Use `payload.deliver: true` when you want last-route delivery without an explicit `to`. +- Use `payload.deliver: false` to keep output internal even if a `to` is present. + +If `delivery` is set, it overrides legacy payload delivery fields and skips the legacy main-session summary. Target format reminders: @@ -248,13 +268,14 @@ Recurring, isolated job with delivery: "wakeMode": "next-heartbeat", "payload": { "kind": "agentTurn", - "message": "Summarize overnight updates.", - "deliver": true, + "message": "Summarize overnight updates." + }, + "delivery": { + "mode": "announce", "channel": "slack", "to": "channel:C1234567890", - "bestEffortDeliver": true - }, - "isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" } + "bestEffort": true + } } ``` @@ -263,7 +284,7 @@ Notes: - `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). - `atMs` and `everyMs` are epoch milliseconds. - `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. -- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `isolation`. +- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `delivery`, `isolation`. - `wakeMode` defaults to `"next-heartbeat"` when omitted. ### cron.update params @@ -341,7 +362,7 @@ openclaw cron add \ --wake now ``` -Recurring isolated job (deliver to WhatsApp): +Recurring isolated job (announce to WhatsApp): ```bash openclaw cron add \ @@ -350,7 +371,7 @@ openclaw cron add \ --tz "America/Los_Angeles" \ --session isolated \ --message "Summarize inbox + calendar for today." \ - --deliver \ + --announce \ --channel whatsapp \ --to "+15551234567" ``` diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 5ca0a866be..cc22a63aea 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -90,7 +90,8 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect - **Exact timing**: 5-field cron expressions with timezone support. - **Session isolation**: Runs in `cron:` without polluting main history. - **Model overrides**: Use a cheaper or more powerful model per job. -- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable). +- **Delivery control**: Choose `announce` (summary), `deliver` (full output), or `none`. Legacy jobs still post a summary to main by default. +- **Immediate delivery**: Announce/deliver modes post directly without waiting for heartbeat. - **No agent context needed**: Runs even if main session is idle or compacted. - **One-shot support**: `--at` for precise future timestamps. @@ -104,12 +105,12 @@ openclaw cron add \ --session isolated \ --message "Generate today's briefing: weather, calendar, top emails, news summary." \ --model opus \ - --deliver \ + --announce \ --channel whatsapp \ --to "+15551234567" ``` -This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp. +This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp. ### Cron example: One-shot reminder @@ -173,7 +174,7 @@ The most efficient setup uses **both**: ```bash # Daily morning briefing at 7am -openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver +openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce # Weekly project review on Mondays at 9am openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus @@ -245,7 +246,7 @@ Use `--session isolated` when you want: - A clean slate without prior context - Different model or thinking settings -- Output delivered directly to a channel (summary still posts to main by default) +- Announce summaries or deliver full output directly to a channel - History that doesn't clutter main session ```bash @@ -256,7 +257,7 @@ openclaw cron add \ --message "Weekly codebase analysis..." \ --model opus \ --thinking high \ - --deliver + --announce ``` ## Cost Considerations diff --git a/docs/cli/cron.md b/docs/cli/cron.md index ff09989ff0..02d6a4afb9 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -21,7 +21,7 @@ Tip: run `openclaw cron --help` for the full command surface. Update delivery settings without changing the message: ```bash -openclaw cron edit --deliver --channel telegram --to "123456789" +openclaw cron edit --announce --channel telegram --to "123456789" ``` Disable delivery for an isolated job: @@ -29,3 +29,9 @@ Disable delivery for an isolated job: ```bash openclaw cron edit --no-deliver ``` + +Deliver full output (instead of announce): + +```bash +openclaw cron edit --deliver --channel slack --to "channel:C1234567890" +``` diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b3add633df..5438c4592b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -79,6 +79,11 @@ you revoke it with `openclaw devices revoke --device --role `. See - Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Update: run a package/git update + restart (`update.run`) with a restart report +Cron jobs panel notes: + +- For isolated jobs, choose a delivery mode: legacy main summary, announce summary, deliver full output, or none. +- Channel/target fields appear when announce or deliver is selected. + ## Chat behavior - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 13bbd8fa80..774efff47b 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -174,6 +174,7 @@ JOB SCHEMA (for add action): "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute + "delivery": { ... }, // Optional: announce/deliver output (isolated only) "sessionTarget": "main" | "isolated", // Required "enabled": true | false // Optional, default true } @@ -190,7 +191,13 @@ PAYLOAD TYPES (payload.kind): - "systemEvent": Injects text as system event into session { "kind": "systemEvent", "text": "" } - "agentTurn": Runs agent with message (isolated sessions only) - { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": , "deliver": , "channel": "", "to": "", "bestEffortDeliver": } + { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } + +DELIVERY (isolated-only, top-level): + { "mode": "none|announce|deliver", "channel": "", "to": "", "bestEffort": } + +LEGACY DELIVERY (payload, only when delivery is omitted): + { "deliver": , "channel": "", "to": "", "bestEffortDeliver": } CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 4176966d0b..3fa71e930b 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -213,20 +213,15 @@ describe("cron cli", () => { const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); const patch = updateCall?.[2] as { patch?: { - payload?: { - kind?: string; - message?: string; - deliver?: boolean; - channel?: string; - to?: string; - }; + payload?: { kind?: string; message?: string }; + delivery?: { mode?: string; channel?: string; to?: string }; }; }; expect(patch?.patch?.payload?.kind).toBe("agentTurn"); - expect(patch?.patch?.payload?.deliver).toBe(true); - expect(patch?.patch?.payload?.channel).toBe("telegram"); - expect(patch?.patch?.payload?.to).toBe("19098680"); + expect(patch?.patch?.delivery?.mode).toBe("deliver"); + expect(patch?.patch?.delivery?.channel).toBe("telegram"); + expect(patch?.patch?.delivery?.to).toBe("19098680"); expect(patch?.patch?.payload?.message).toBeUndefined(); }); @@ -242,11 +237,11 @@ describe("cron cli", () => { const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); const patch = updateCall?.[2] as { - patch?: { payload?: { kind?: string; deliver?: boolean } }; + patch?: { payload?: { kind?: string }; delivery?: { mode?: string } }; }; expect(patch?.patch?.payload?.kind).toBe("agentTurn"); - expect(patch?.patch?.payload?.deliver).toBe(false); + expect(patch?.patch?.delivery?.mode).toBe("none"); }); it("does not include undefined delivery fields when updating message", async () => { @@ -272,6 +267,7 @@ describe("cron cli", () => { to?: string; bestEffortDeliver?: boolean; }; + delivery?: unknown; }; }; @@ -283,6 +279,7 @@ describe("cron cli", () => { expect(patch?.patch?.payload).not.toHaveProperty("channel"); expect(patch?.patch?.payload).not.toHaveProperty("to"); expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver"); + expect(patch?.patch).not.toHaveProperty("delivery"); }); it("includes delivery fields when explicitly provided with message", async () => { @@ -313,20 +310,16 @@ describe("cron cli", () => { const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); const patch = updateCall?.[2] as { patch?: { - payload?: { - message?: string; - deliver?: boolean; - channel?: string; - to?: string; - }; + payload?: { message?: string }; + delivery?: { mode?: string; channel?: string; to?: string }; }; }; // Should include everything expect(patch?.patch?.payload?.message).toBe("Updated message"); - expect(patch?.patch?.payload?.deliver).toBe(true); - expect(patch?.patch?.payload?.channel).toBe("telegram"); - expect(patch?.patch?.payload?.to).toBe("19098680"); + expect(patch?.patch?.delivery?.mode).toBe("deliver"); + expect(patch?.patch?.delivery?.channel).toBe("telegram"); + expect(patch?.patch?.delivery?.to).toBe("19098680"); }); it("includes best-effort delivery when provided with message", async () => { diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 0254a8188c..31a0260d61 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -80,11 +80,12 @@ export function registerCronAddCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") + .option("--announce", "Announce summary to a chat (subagent-style)", false) .option( "--deliver", - "Deliver agent output (required when using last-route delivery without --to)", - false, + "Deliver full output to a chat (required when using last-route delivery without --to)", ) + .option("--no-deliver", "Disable delivery and skip main-session summary") .option("--channel ", `Delivery channel (${getCronChannelOptions()})`, "last") .option( "--to ", @@ -158,6 +159,15 @@ export function registerCronAddCommand(cron: Command) { return { kind: "systemEvent" as const, text: systemEvent }; } const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds); + const hasAnnounce = Boolean(opts.announce); + const hasDeliver = opts.deliver === true; + const hasNoDeliver = opts.deliver === false; + const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter( + Boolean, + ).length; + if (deliveryFlagCount > 1) { + throw new Error("Choose at most one of --announce, --deliver, or --no-deliver"); + } return { kind: "agentTurn" as const, message, @@ -169,10 +179,15 @@ export function registerCronAddCommand(cron: Command) { : undefined, timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, - deliver: opts.deliver ? true : undefined, - channel: typeof opts.channel === "string" ? opts.channel : "last", + channel: + typeof opts.channel === "string" && opts.channel.trim() + ? opts.channel.trim() + : "last", to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, - bestEffortDeliver: opts.bestEffortDeliver ? true : undefined, + bestEffortDeliver: + !hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver + ? true + : undefined, }; })(); @@ -182,6 +197,12 @@ export function registerCronAddCommand(cron: Command) { if (sessionTarget === "isolated" && payload.kind !== "agentTurn") { throw new Error("Isolated jobs require --message (agentTurn)."); } + if ( + (opts.announce || typeof opts.deliver === "boolean") && + (sessionTarget !== "isolated" || payload.kind !== "agentTurn") + ) { + throw new Error("--announce/--deliver/--no-deliver require --session isolated."); + } const isolation = sessionTarget === "isolated" @@ -222,6 +243,20 @@ export function registerCronAddCommand(cron: Command) { sessionTarget, wakeMode, payload, + delivery: + payload.kind === "agentTurn" && + sessionTarget === "isolated" && + (opts.announce || typeof opts.deliver === "boolean") + ? { + mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none", + channel: + typeof opts.channel === "string" && opts.channel.trim() + ? opts.channel.trim() + : "last", + to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, + bestEffort: opts.bestEffortDeliver ? true : undefined, + } + : undefined, isolation, }; diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 340bf64bad..099c97e3f1 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -46,9 +46,10 @@ export function registerCronEditCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs") .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") + .option("--announce", "Announce summary to a chat (subagent-style)") .option( "--deliver", - "Deliver agent output (required when using last-route delivery without --to)", + "Deliver full output to a chat (required when using last-route delivery without --to)", ) .option("--no-deliver", "Disable delivery") .option("--channel ", `Delivery channel (${getCronChannelOptions()})`) @@ -74,6 +75,9 @@ export function registerCronEditCommand(cron: Command) { if (opts.session === "main" && typeof opts.postPrefix === "string") { throw new Error("--post-prefix only applies to isolated jobs."); } + if (opts.announce && typeof opts.deliver === "boolean") { + throw new Error("Choose --announce, --deliver, or --no-deliver (not multiple)."); + } const patch: Record = {}; if (typeof opts.name === "string") { @@ -151,15 +155,16 @@ export function registerCronEditCommand(cron: Command) { ? Number.parseInt(String(opts.timeoutSeconds), 10) : undefined; const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds)); + const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean"; + const hasDeliveryTarget = typeof opts.channel === "string" || typeof opts.to === "string"; + const hasBestEffort = typeof opts.bestEffortDeliver === "boolean"; const hasAgentTurnPatch = typeof opts.message === "string" || Boolean(model) || Boolean(thinking) || hasTimeoutSeconds || - typeof opts.deliver === "boolean" || - typeof opts.channel === "string" || - typeof opts.to === "string" || - typeof opts.bestEffortDeliver === "boolean"; + hasDeliveryModeFlag || + (!hasDeliveryModeFlag && (hasDeliveryTarget || hasBestEffort)); if (hasSystemEventPatch && hasAgentTurnPatch) { throw new Error("Choose at most one payload change"); } @@ -174,15 +179,21 @@ export function registerCronEditCommand(cron: Command) { assignIf(payload, "model", model, Boolean(model)); assignIf(payload, "thinking", thinking, Boolean(thinking)); assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds); - assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean"); - assignIf(payload, "channel", opts.channel, typeof opts.channel === "string"); - assignIf(payload, "to", opts.to, typeof opts.to === "string"); - assignIf( - payload, - "bestEffortDeliver", - opts.bestEffortDeliver, - typeof opts.bestEffortDeliver === "boolean", - ); + if (!hasDeliveryModeFlag) { + const channel = + typeof opts.channel === "string" && opts.channel.trim() + ? opts.channel.trim() + : undefined; + const to = typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined; + assignIf(payload, "channel", channel, Boolean(channel)); + assignIf(payload, "to", to, Boolean(to)); + assignIf( + payload, + "bestEffortDeliver", + opts.bestEffortDeliver, + typeof opts.bestEffortDeliver === "boolean", + ); + } patch.payload = payload; } @@ -192,6 +203,24 @@ export function registerCronEditCommand(cron: Command) { }; } + if (hasDeliveryModeFlag) { + const deliveryMode = opts.announce + ? "announce" + : opts.deliver === true + ? "deliver" + : "none"; + patch.delivery = { + mode: deliveryMode, + channel: + typeof opts.channel === "string" && opts.channel.trim() + ? opts.channel.trim() + : undefined, + to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, + bestEffort: + typeof opts.bestEffortDeliver === "boolean" ? opts.bestEffortDeliver : undefined, + }; + } + const res = await callGatewayFromCli("cron.update", opts, { id, patch, diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts new file mode 100644 index 0000000000..5a40e1ac11 --- /dev/null +++ b/src/cron/delivery.ts @@ -0,0 +1,80 @@ +import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js"; + +export type CronDeliveryPlan = { + mode: CronDeliveryMode; + channel: CronMessageChannel; + to?: string; + bestEffort: boolean; + source: "delivery" | "payload"; + requested: boolean; + legacyMode?: "explicit" | "auto" | "off"; +}; + +function normalizeChannel(value: unknown): CronMessageChannel | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + return trimmed as CronMessageChannel; +} + +function normalizeTo(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { + const payload = job.payload.kind === "agentTurn" ? job.payload : null; + const delivery = job.delivery; + const hasDelivery = delivery && typeof delivery === "object"; + const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined; + const mode = + rawMode === "none" || rawMode === "announce" || rawMode === "deliver" ? rawMode : undefined; + + const payloadChannel = normalizeChannel(payload?.channel); + const payloadTo = normalizeTo(payload?.to); + const payloadBestEffort = payload?.bestEffortDeliver === true; + + const deliveryChannel = normalizeChannel( + (delivery as { channel?: unknown } | undefined)?.channel, + ); + const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to); + const deliveryBestEffortRaw = (delivery as { bestEffort?: unknown } | undefined)?.bestEffort; + const deliveryBestEffort = + typeof deliveryBestEffortRaw === "boolean" ? deliveryBestEffortRaw : undefined; + + const channel = (deliveryChannel ?? payloadChannel ?? "last") as CronMessageChannel; + const to = deliveryTo ?? payloadTo; + if (hasDelivery) { + const resolvedMode = mode ?? "none"; + return { + mode: resolvedMode, + channel, + to, + bestEffort: deliveryBestEffort ?? false, + source: "delivery", + requested: resolvedMode !== "none", + }; + } + + const legacyMode = + payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto"; + const hasExplicitTarget = Boolean(to); + const requested = legacyMode === "explicit" || (legacyMode === "auto" && hasExplicitTarget); + + return { + mode: requested ? "deliver" : "none", + channel, + to, + bestEffort: payloadBestEffort, + source: "payload", + requested, + legacyMode, + }; +} diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 75d5853a64..5be448b2c1 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -24,6 +24,7 @@ export async function resolveDeliveryTarget( channel: Exclude; to?: string; accountId?: string; + threadId?: string | number; mode: "explicit" | "implicit"; error?: Error; }> { @@ -69,7 +70,13 @@ export async function resolveDeliveryTarget( const toCandidate = resolved.to; if (!toCandidate) { - return { channel, to: undefined, accountId: resolved.accountId, mode }; + return { + channel, + to: undefined, + accountId: resolved.accountId, + threadId: resolved.threadId, + mode, + }; } const docked = resolveOutboundTarget({ @@ -83,6 +90,7 @@ export async function resolveDeliveryTarget( channel, to: docked.ok ? docked.to : undefined, accountId: resolved.accountId, + threadId: resolved.threadId, mode, error: docked.ok ? undefined : docked.error, }; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index e3f6bc91da..3ccef96e6a 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -31,6 +31,10 @@ import { import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; +import { + runSubagentAnnounceFlow, + type SubagentRunOutcome, +} from "../../agents/subagent-announce.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { ensureAgentWorkspace } from "../../agents/workspace.js"; @@ -41,7 +45,11 @@ import { supportsXHighThinking, } from "../../auto-reply/thinking.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; -import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js"; +import { + resolveAgentMainSessionKey, + resolveSessionTranscriptPath, + updateSessionStore, +} from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; @@ -53,6 +61,7 @@ import { getHookType, isExternalHookSession, } from "../../security/external-content.js"; +import { resolveCronDeliveryPlan } from "../delivery.js"; import { resolveDeliveryTarget } from "./delivery-target.js"; import { isHeartbeatOnlyResponse, @@ -231,16 +240,15 @@ export async function runCronIsolatedAgentTurn(params: { }); const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null; - const deliveryMode = - agentPayload?.deliver === true ? "explicit" : agentPayload?.deliver === false ? "off" : "auto"; - const hasExplicitTarget = Boolean(agentPayload?.to && agentPayload.to.trim()); - const deliveryRequested = - deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget); - const bestEffortDeliver = agentPayload?.bestEffortDeliver === true; + const deliveryPlan = resolveCronDeliveryPlan(params.job); + const deliveryRequested = deliveryPlan.requested; + const bestEffortDeliver = deliveryPlan.bestEffort; + const legacyDeliveryMode = + deliveryPlan.source === "payload" ? deliveryPlan.legacyMode : undefined; const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, { - channel: agentPayload?.channel ?? "last", - to: agentPayload?.to, + channel: deliveryPlan.channel ?? "last", + to: deliveryPlan.to, }); const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone); @@ -424,7 +432,7 @@ export async function runCronIsolatedAgentTurn(params: { const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); const skipMessagingToolDelivery = deliveryRequested && - deliveryMode === "auto" && + legacyDeliveryMode === "auto" && runResult.didSendViaMessagingTool === true && (runResult.messagingToolSentTargets ?? []).some((target) => matchesMessagingToolDeliveryTarget(target, { @@ -435,38 +443,70 @@ export async function runCronIsolatedAgentTurn(params: { ); if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) { - if (!resolvedDelivery.to) { - const reason = - resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to)."; - if (!bestEffortDeliver) { + if (deliveryPlan.mode === "announce") { + const requesterSessionKey = resolveAgentMainSessionKey({ + cfg: cfgWithAgentDefaults, + agentId, + }); + const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim()); + const requesterOrigin = useExplicitOrigin + ? { + channel: resolvedDelivery.channel, + to: resolvedDelivery.to, + accountId: resolvedDelivery.accountId, + threadId: resolvedDelivery.threadId, + } + : undefined; + const outcome: SubagentRunOutcome = { status: "ok" }; + const taskLabel = params.job.name?.trim() || "cron job"; + await runSubagentAnnounceFlow({ + childSessionKey: agentSessionKey, + childRunId: cronSession.sessionEntry.sessionId, + requesterSessionKey, + requesterOrigin, + requesterDisplayKey: requesterSessionKey, + task: taskLabel, + timeoutMs: 30_000, + cleanup: "keep", + roundOneReply: outputText ?? summary, + waitForCompletion: false, + label: `Cron: ${taskLabel}`, + outcome, + }); + } else { + if (!resolvedDelivery.to) { + const reason = + resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to)."; + if (!bestEffortDeliver) { + return { + status: "error", + summary, + outputText, + error: reason, + }; + } return { - status: "error", - summary, + status: "skipped", + summary: `Delivery skipped (${reason}).`, outputText, - error: reason, }; } - return { - status: "skipped", - summary: `Delivery skipped (${reason}).`, - outputText, - }; - } - try { - await deliverOutboundPayloads({ - cfg: cfgWithAgentDefaults, - channel: resolvedDelivery.channel, - to: resolvedDelivery.to, - accountId: resolvedDelivery.accountId, - payloads, - bestEffort: bestEffortDeliver, - deps: createOutboundSendDeps(params.deps), - }); - } catch (err) { - if (!bestEffortDeliver) { - return { status: "error", summary, outputText, error: String(err) }; + try { + await deliverOutboundPayloads({ + cfg: cfgWithAgentDefaults, + channel: resolvedDelivery.channel, + to: resolvedDelivery.to, + accountId: resolvedDelivery.accountId, + payloads, + bestEffort: bestEffortDeliver, + deps: createOutboundSendDeps(params.deps), + }); + } catch (err) { + if (!bestEffortDeliver) { + return { status: "error", summary, outputText, error: String(err) }; + } + return { status: "ok", summary, outputText }; } - return { status: "ok", summary, outputText }; } } diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 12bd6e587d..d73a3d89e9 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -110,4 +110,28 @@ describe("normalizeCronJobCreate", () => { expect(schedule.kind).toBe("at"); expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z")); }); + + it("normalizes delivery mode and channel", () => { + const normalized = normalizeCronJobCreate({ + name: "delivery", + enabled: true, + schedule: { kind: "cron", expr: "* * * * *" }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: "hi", + }, + delivery: { + mode: " ANNOUNCE ", + channel: " TeLeGrAm ", + to: " 7200373102 ", + }, + }) as unknown as Record; + + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBe("announce"); + expect(delivery.channel).toBe("telegram"); + expect(delivery.to).toBe("7200373102"); + }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 13b5cb2898..2f83b29373 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -61,6 +61,30 @@ function coercePayload(payload: UnknownRecord) { return next; } +function coerceDelivery(delivery: UnknownRecord) { + const next: UnknownRecord = { ...delivery }; + if (typeof delivery.mode === "string") { + next.mode = delivery.mode.trim().toLowerCase(); + } + if (typeof delivery.channel === "string") { + const trimmed = delivery.channel.trim().toLowerCase(); + if (trimmed) { + next.channel = trimmed; + } else { + delete next.channel; + } + } + if (typeof delivery.to === "string") { + const trimmed = delivery.to.trim(); + if (trimmed) { + next.to = trimmed; + } else { + delete next.to; + } + } + return next; +} + function unwrapJob(raw: UnknownRecord) { if (isRecord(raw.data)) { return raw.data; @@ -118,6 +142,10 @@ export function normalizeCronJobInput( next.payload = coercePayload(base.payload); } + if (isRecord(base.delivery)) { + next.delivery = coerceDelivery(base.delivery); + } + if (options.applyDefaults) { if (!next.wakeMode) { next.wakeMode = "next-heartbeat"; diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index e0d566ce35..5525176985 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -1,5 +1,7 @@ import crypto from "node:crypto"; import type { + CronDelivery, + CronDeliveryPatch, CronJob, CronJobCreate, CronJobPatch, @@ -26,6 +28,12 @@ export function assertSupportedJobSpec(job: Pick) { + if (job.delivery && job.sessionTarget !== "isolated") { + throw new Error('cron delivery config is only supported for sessionTarget="isolated"'); + } +} + export function findJobOrThrow(state: CronServiceState, id: string) { const job = state.store?.jobs.find((j) => j.id === id); if (!job) { @@ -102,12 +110,14 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo sessionTarget: input.sessionTarget, wakeMode: input.wakeMode, payload: input.payload, + delivery: input.delivery, isolation: input.isolation, state: { ...input.state, }, }; assertSupportedJobSpec(job); + assertDeliverySupport(job); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); return job; } @@ -137,6 +147,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (patch.payload) { job.payload = mergeCronPayload(job.payload, patch.payload); } + if (patch.delivery) { + job.delivery = mergeCronDelivery(job.delivery, patch.delivery); + } if (patch.isolation) { job.isolation = patch.isolation; } @@ -147,6 +160,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId); } assertSupportedJobSpec(job); + assertDeliverySupport(job); } function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload { @@ -219,6 +233,35 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload { }; } +function mergeCronDelivery( + existing: CronDelivery | undefined, + patch: CronDeliveryPatch, +): CronDelivery { + const next: CronDelivery = { + mode: existing?.mode ?? "none", + channel: existing?.channel, + to: existing?.to, + bestEffort: existing?.bestEffort, + }; + + if (typeof patch.mode === "string") { + next.mode = patch.mode; + } + if ("channel" in patch) { + const channel = typeof patch.channel === "string" ? patch.channel.trim() : ""; + next.channel = channel ? channel : undefined; + } + if ("to" in patch) { + const to = typeof patch.to === "string" ? patch.to.trim() : ""; + next.to = to ? to : undefined; + } + if (typeof patch.bestEffort === "boolean") { + next.bestEffort = patch.bestEffort; + } + + return next; +} + export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) { if (opts.forced) { return true; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index d7672c0d24..3afcaa2fe8 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -125,7 +125,7 @@ export async function executeJob( emit(state, { jobId: job.id, action: "removed" }); } - if (job.sessionTarget === "isolated") { + if (job.sessionTarget === "isolated" && !job.delivery) { const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron"; const mode = job.isolation?.postToMainMode ?? "summary"; diff --git a/src/cron/types.ts b/src/cron/types.ts index f3fd891d6c..ed70fe1d11 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -10,6 +10,17 @@ export type CronWakeMode = "next-heartbeat" | "now"; export type CronMessageChannel = ChannelId | "last"; +export type CronDeliveryMode = "none" | "announce" | "deliver"; + +export type CronDelivery = { + mode: CronDeliveryMode; + channel?: CronMessageChannel; + to?: string; + bestEffort?: boolean; +}; + +export type CronDeliveryPatch = Partial; + export type CronPayload = | { kind: "systemEvent"; text: string } | { @@ -75,6 +86,7 @@ export type CronJob = { sessionTarget: CronSessionTarget; wakeMode: CronWakeMode; payload: CronPayload; + delivery?: CronDelivery; isolation?: CronIsolation; state: CronJobState; }; @@ -90,5 +102,6 @@ export type CronJobCreate = Omit> & { payload?: CronPayloadPatch; + delivery?: CronDeliveryPatch; state?: Partial; }; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 47c26ec91e..e4a0082b86 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -75,6 +75,28 @@ export const CronPayloadPatchSchema = Type.Union([ ), ]); +export const CronDeliverySchema = Type.Object( + { + mode: Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]), + channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), + to: Type.Optional(Type.String()), + bestEffort: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const CronDeliveryPatchSchema = Type.Object( + { + mode: Type.Optional( + Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]), + ), + channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), + to: Type.Optional(Type.String()), + bestEffort: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + export const CronIsolationSchema = Type.Object( { postToMainPrefix: Type.Optional(Type.String()), @@ -112,6 +134,7 @@ export const CronJobSchema = Type.Object( sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), payload: CronPayloadSchema, + delivery: Type.Optional(CronDeliverySchema), isolation: Type.Optional(CronIsolationSchema), state: CronJobStateSchema, }, @@ -138,6 +161,7 @@ export const CronAddParamsSchema = Type.Object( sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), payload: CronPayloadSchema, + delivery: Type.Optional(CronDeliverySchema), isolation: Type.Optional(CronIsolationSchema), }, { additionalProperties: false }, @@ -154,6 +178,7 @@ export const CronJobPatchSchema = Type.Object( sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])), wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])), payload: Type.Optional(CronPayloadPatchSchema), + delivery: Type.Optional(CronDeliveryPatchSchema), isolation: Type.Optional(CronIsolationSchema), state: Type.Optional(Type.Partial(CronJobStateSchema)), }, diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 61028bfdab..79a9977c6d 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -25,9 +25,9 @@ export const DEFAULT_CRON_FORM: CronFormState = { wakeMode: "next-heartbeat", payloadKind: "systemEvent", payloadText: "", - deliver: false, - channel: "last", - to: "", + deliveryMode: "legacy", + deliveryChannel: "last", + deliveryTo: "", timeoutSeconds: "", postToMainPrefix: "", }; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 836415eb69..970b191d5e 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -88,20 +88,8 @@ export function buildCronPayload(form: CronFormState) { const payload: { kind: "agentTurn"; message: string; - deliver?: boolean; - channel?: string; - to?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; - if (form.deliver) { - payload.deliver = true; - } - if (form.channel) { - payload.channel = form.channel; - } - if (form.to.trim()) { - payload.to = form.to.trim(); - } const timeoutSeconds = toNumber(form.timeoutSeconds, 0); if (timeoutSeconds > 0) { payload.timeoutSeconds = timeoutSeconds; @@ -118,6 +106,21 @@ export async function addCronJob(state: CronState) { try { const schedule = buildCronSchedule(state.cronForm); const payload = buildCronPayload(state.cronForm); + const delivery = + state.cronForm.sessionTarget === "isolated" && + state.cronForm.payloadKind === "agentTurn" && + state.cronForm.deliveryMode !== "legacy" + ? { + mode: + state.cronForm.deliveryMode === "announce" + ? "announce" + : state.cronForm.deliveryMode === "deliver" + ? "deliver" + : "none", + channel: state.cronForm.deliveryChannel.trim() || "last", + to: state.cronForm.deliveryTo.trim() || undefined, + } + : undefined; const agentId = state.cronForm.agentId.trim(); const job = { name: state.cronForm.name.trim(), @@ -128,8 +131,11 @@ export async function addCronJob(state: CronState) { sessionTarget: state.cronForm.sessionTarget, wakeMode: state.cronForm.wakeMode, payload, + delivery, isolation: - state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated" + state.cronForm.postToMainPrefix.trim() && + state.cronForm.sessionTarget === "isolated" && + state.cronForm.deliveryMode === "legacy" ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() } : undefined, }; diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index a6738b6f8f..9704d29d72 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -66,5 +66,18 @@ export function formatCronPayload(job: CronJob) { if (p.kind === "systemEvent") { return `System: ${p.text}`; } - return `Agent: ${p.message}`; + const base = `Agent: ${p.message}`; + const delivery = job.delivery; + if (delivery && delivery.mode !== "none") { + const target = + delivery.channel || delivery.to + ? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})` + : ""; + return `${base} · ${delivery.mode}${target}`; + } + if (!delivery && (p.deliver || p.to)) { + const target = p.channel || p.to ? ` (${p.channel ?? "last"}${p.to ? ` -> ${p.to}` : ""})` : ""; + return `${base} · deliver${target}`; + } + return base; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 36fe4a77f1..8548e3141f 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -440,7 +440,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - provider?: + channel?: | "last" | "whatsapp" | "telegram" @@ -453,6 +453,13 @@ export type CronPayload = bestEffortDeliver?: boolean; }; +export type CronDelivery = { + mode: "none" | "announce" | "deliver"; + channel?: string; + to?: string; + bestEffort?: boolean; +}; + export type CronIsolation = { postToMainPrefix?: string; }; @@ -479,6 +486,7 @@ export type CronJob = { sessionTarget: CronSessionTarget; wakeMode: CronWakeMode; payload: CronPayload; + delivery?: CronDelivery; isolation?: CronIsolation; state?: CronJobState; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index afb80c179b..258fe165e1 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -29,9 +29,9 @@ export type CronFormState = { wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; - deliver: boolean; - channel: string; - to: string; + deliveryMode: "legacy" | "none" | "announce" | "deliver"; + deliveryChannel: string; + deliveryTo: string; timeoutSeconds: string; postToMainPrefix: string; }; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 216d8f01ca..db5682ca07 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -32,7 +32,7 @@ export type CronProps = { function buildChannelOptions(props: CronProps): string[] { const options = ["last", ...props.channels.filter(Boolean)]; - const current = props.form.channel?.trim(); + const current = props.form.deliveryChannel?.trim(); if (current && !options.includes(current)) { options.push(current); } @@ -197,77 +197,90 @@ export function renderCron(props: CronProps) { rows="4" > - ${ - props.form.payloadKind === "agentTurn" - ? html` -
- - - - - ${ - props.form.sessionTarget === "isolated" - ? html` - - ` - : nothing - } -
- ` - : nothing - } + > + + + + + + + + ${ + props.form.deliveryMode === "announce" || props.form.deliveryMode === "deliver" + ? html` + + + ` + : nothing + } + ${ + props.form.sessionTarget === "isolated" && props.form.deliveryMode === "legacy" + ? html` + + ` + : nothing + } + + ` + : nothing + }