feat(cron): introduce delivery modes for isolated jobs

- Added support for new delivery modes in cron jobs: `announce`, `deliver`, and `none`.
- Updated documentation to reflect changes in delivery options and usage examples.
- Enhanced the cron job schema to include delivery configuration.
- Refactored related CLI commands and UI components to accommodate the new delivery settings.
- Improved handling of legacy delivery fields for backward compatibility.

This update allows users to choose how output from isolated jobs is delivered, enhancing flexibility in job management.
This commit is contained in:
Tyler Yust
2026-02-03 13:44:29 -08:00
committed by Peter Steinberger
parent 3a03e38378
commit 511c656cbc
24 changed files with 604 additions and 205 deletions

View File

@@ -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.

View File

@@ -23,7 +23,7 @@ cron is the mechanism.
- Jobs persist under `~/.openclaw/cron/` so restarts dont 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:<jobId>`, optionally deliver output.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, 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:<jobId> <job name>]` 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 sessions “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 sessions
“last route” (the last place the agent replied).
- If `to` is set, cron auto-delivers the agents 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 agents 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"
```

View File

@@ -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:<jobId>` 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

View File

@@ -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 <job-id> --deliver --channel telegram --to "123456789"
openclaw cron edit <job-id> --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 <job-id> --no-deliver
```
Deliver full output (instead of announce):
```bash
openclaw cron edit <job-id> --deliver --channel slack --to "channel:C1234567890"
```

View File

@@ -79,6 +79,11 @@ you revoke it with `openclaw devices revoke --device <id> --role <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.

View File

@@ -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": "<message>" }
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
LEGACY DELIVERY (payload, only when delivery is omitted):
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"

View File

@@ -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 () => {

View File

@@ -80,11 +80,12 @@ export function registerCronAddCommand(cron: Command) {
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "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 <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option(
"--to <dest>",
@@ -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,
};

View File

@@ -46,9 +46,10 @@ export function registerCronEditCommand(cron: Command) {
.option("--thinking <level>", "Thinking level for agent jobs")
.option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "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 <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<string, unknown> = {};
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,

80
src/cron/delivery.ts Normal file
View File

@@ -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,
};
}

View File

@@ -24,6 +24,7 @@ export async function resolveDeliveryTarget(
channel: Exclude<OutboundChannel, "none">;
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,
};

View File

@@ -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 };
}
}

View File

@@ -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<string, unknown>;
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
});
});

View File

@@ -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";

View File

@@ -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<CronJob, "sessionTarget" | "pay
}
}
function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">) {
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;

View File

@@ -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";

View File

@@ -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<CronDelivery>;
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<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
payload?: CronPayloadPatch;
delivery?: CronDeliveryPatch;
state?: Partial<CronJobState>;
};

View File

@@ -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)),
},

View File

@@ -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: "",
};

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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"
></textarea>
</label>
${
props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
<input
type="checkbox"
.checked=${props.form.deliver}
@change=${(e: Event) =>
props.onFormChange({
deliver: (e.target as HTMLInputElement).checked,
})}
/>
</label>
<label class="field">
<span>Channel</span>
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
${
props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Delivery</span>
<select
.value=${props.form.deliveryMode}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value,
deliveryMode: (e.target as HTMLSelectElement)
.value as CronFormState["deliveryMode"],
})}
>
${channelOptions.map(
(channel) =>
html`<option value=${channel}>
${resolveChannelLabel(props, channel)}
</option>`,
)}
</select>
</label>
<label class="field">
<span>To</span>
<input
.value=${props.form.to}
@input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
/>
</label>
<label class="field">
<span>Timeout (seconds)</span>
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value,
})}
/>
</label>
${
props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
/>
</label>
`
: nothing
}
</div>
`
: nothing
}
>
<option value="legacy">Main summary (legacy)</option>
<option value="announce">Announce summary</option>
<option value="deliver">Deliver full output</option>
<option value="none">None (internal)</option>
</select>
</label>
<label class="field">
<span>Timeout (seconds)</span>
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value,
})}
/>
</label>
${
props.form.deliveryMode === "announce" || props.form.deliveryMode === "deliver"
? html`
<label class="field">
<span>Channel</span>
<select
.value=${props.form.deliveryChannel || "last"}
@change=${(e: Event) =>
props.onFormChange({
deliveryChannel: (e.target as HTMLSelectElement).value,
})}
>
${channelOptions.map(
(channel) =>
html`<option value=${channel}>
${resolveChannelLabel(props, channel)}
</option>`,
)}
</select>
</label>
<label class="field">
<span>To</span>
<input
.value=${props.form.deliveryTo}
@input=${(e: Event) =>
props.onFormChange({
deliveryTo: (e.target as HTMLInputElement).value,
})}
placeholder="+1555… or chat id"
/>
</label>
`
: nothing
}
${
props.form.sessionTarget === "isolated" && props.form.deliveryMode === "legacy"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value,
})}
/>
</label>
`
: nothing
}
</div>
`
: nothing
}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}