+
+
+
+
+
Morning Briefing
+
+ {statusLabel}
+
+
+ {showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
+
+ {formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
+
+ )}
+ {showScheduleDetails && (
+
+ Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
+
+ )}
+ {showLastDelivery && (
+
+ Last delivery:{' '}
+ {lastDelivery
+ .map(entry => {
+ const channel = deliveryChannelLabel[entry.channel] ?? entry.channel;
+ const status = deliveryStatusLabel[entry.status] ?? entry.status;
+ const reason = entry.reason
+ ? (deliveryReasonLabel[entry.reason] ?? entry.reason)
+ : undefined;
+ return reason ? `${channel} (${status}: ${reason})` : `${channel} (${status})`;
+ })
+ .join(' • ')}
+
+ )}
+
+ {isWarmupState && (
+
+ Instance is still warming up. Morning Briefing controls will become available once
+ the gateway is fully ready.
+
+ )}
+
+
{sourceSummaryText}
- {showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
-
- {formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
-
- )}
- {showScheduleDetails && (
-
- Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
-
- )}
- {isWarmupState && (
-
- Instance is still warming up. Morning Briefing controls will become available once the
- gateway is fully ready.
-
- )}
-
{!desiredEnabled && controlsEnabled && (
Enable Morning Briefing to get a personalized briefing everyday.
)}
-
{sourceSummaryText}
-
{requestedDay && (
{isReading ? (
@@ -1314,10 +1342,40 @@ export function SettingsTab({
isControllerVersionError,
} = useClawUpdateAvailable(status);
const { data: myPin } = useClawMyPin();
- const { data: morningBriefingStatus } = useClawMorningBriefingStatus(true);
- const { data: gatewayReady } = useClawGatewayReady(isRunning);
+ const morningBriefingStatusQuery = useClawMorningBriefingStatus(isRunning);
+ const morningBriefingStatus = morningBriefingStatusQuery.data;
+ const gatewayReadyQuery = useClawGatewayReady(isRunning);
+ const gatewayReady = gatewayReadyQuery.data;
const [confirmDestroy, setConfirmDestroy] = useState(false);
const [confirmRestore, setConfirmRestore] = useState(false);
+ const [bootStartedAtMs, setBootStartedAtMs] = useState(null);
+ const previousStatusRef = useRef(status.status);
+
+ useEffect(() => {
+ const previousStatus = previousStatusRef.current;
+ if (status.status === 'running' && previousStatus !== 'running') {
+ setBootStartedAtMs(Date.now());
+ }
+ if (status.status !== 'running' && previousStatus === 'running') {
+ setBootStartedAtMs(null);
+ }
+ previousStatusRef.current = status.status;
+ }, [status.status]);
+
+ const hasFreshGatewayReady =
+ isRunning &&
+ (bootStartedAtMs === null || gatewayReadyQuery.dataUpdatedAt >= bootStartedAtMs) &&
+ gatewayReadyQuery.dataUpdatedAt > 0;
+ const hasFreshMorningBriefingStatus =
+ isRunning &&
+ (bootStartedAtMs === null || morningBriefingStatusQuery.dataUpdatedAt >= bootStartedAtMs) &&
+ morningBriefingStatusQuery.dataUpdatedAt > 0;
+ const morningBriefingActionsReady =
+ isRunning &&
+ hasFreshGatewayReady &&
+ hasFreshMorningBriefingStatus &&
+ gatewayReady?.ready === true &&
+ gatewayReady?.settled === true;
const hasModelSelectionError = isRunning && isControllerVersionError;
const modelSelectionError = hasModelSelectionError
? 'Failed to load the running OpenClaw version. Retry before changing the default model.'
@@ -1867,9 +1925,7 @@ export function SettingsTab({
mutations={mutations}
briefingStatus={morningBriefingStatus}
isRunning={isRunning}
- actionsReady={
- isRunning && gatewayReady?.ready === true && gatewayReady?.settled === true
- }
+ actionsReady={morningBriefingActionsReady}
fallbackReadiness={{
githubConfigured: configuredSecrets.github ?? false,
linearConfigured: configuredSecrets.linear ?? false,
diff --git a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts
new file mode 100644
index 000000000..cf1722ffe
--- /dev/null
+++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from '@jest/globals';
+import { deriveMorningBriefingCardState } from './morning-briefing-card-state';
+
+describe('deriveMorningBriefingCardState', () => {
+ test('keeps warmup state when gateway_warming_up payload coexists with stale enabled=false', () => {
+ const state = deriveMorningBriefingCardState({
+ isRunning: true,
+ actionsReady: true,
+ briefingStatus: {
+ code: 'gateway_warming_up',
+ enabled: false,
+ desiredEnabled: false,
+ observedEnabled: false,
+ reconcileState: 'in_progress',
+ },
+ });
+
+ expect(state.isWarmupState).toBe(true);
+ expect(state.isGatewayWarmupStatus).toBe(true);
+ });
+
+ test('exits warmup when actions are ready and status is resolved without warmup code', () => {
+ const state = deriveMorningBriefingCardState({
+ isRunning: true,
+ actionsReady: true,
+ briefingStatus: {
+ enabled: true,
+ desiredEnabled: true,
+ observedEnabled: true,
+ reconcileState: 'succeeded',
+ },
+ });
+
+ expect(state.isWarmupState).toBe(false);
+ expect(state.desiredEnabled).toBe(true);
+ expect(state.observedEnabled).toBe(true);
+ });
+});
diff --git a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts
new file mode 100644
index 000000000..7f21d8a25
--- /dev/null
+++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts
@@ -0,0 +1,39 @@
+import type { MorningBriefingStatusLite } from '@/lib/kiloclaw/types';
+
+export type MorningBriefingCardStateInput = {
+ isRunning: boolean;
+ actionsReady: boolean;
+ briefingStatus: MorningBriefingStatusLite | undefined;
+};
+
+export type MorningBriefingCardState = {
+ desiredEnabled: boolean;
+ observedEnabled: boolean;
+ hasResolvedBriefingToggleState: boolean;
+ isGatewayWarmupStatus: boolean;
+ isWarmupState: boolean;
+};
+
+export function deriveMorningBriefingCardState(
+ input: MorningBriefingCardStateInput
+): MorningBriefingCardState {
+ const desiredEnabledValue = input.briefingStatus?.desiredEnabled ?? input.briefingStatus?.enabled;
+ const observedEnabledValue =
+ input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled;
+ const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up';
+ const hasResolvedBriefingToggleState =
+ typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean';
+ const desiredEnabled = desiredEnabledValue ?? false;
+ const observedEnabled = observedEnabledValue ?? false;
+ const isWarmupState =
+ input.isRunning &&
+ (input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState);
+
+ return {
+ desiredEnabled,
+ observedEnabled,
+ hasResolvedBriefingToggleState,
+ isGatewayWarmupStatus,
+ isWarmupState,
+ };
+}
diff --git a/apps/web/src/hooks/useKiloClaw.ts b/apps/web/src/hooks/useKiloClaw.ts
index 4b8f04d99..18ee66dcd 100644
--- a/apps/web/src/hooks/useKiloClaw.ts
+++ b/apps/web/src/hooks/useKiloClaw.ts
@@ -160,6 +160,11 @@ export function useKiloClawMutations() {
]);
};
+ const clearGatewayAndMorningBriefingCaches = () => {
+ queryClient.removeQueries({ queryKey: trpc.kiloclaw.gatewayReady.queryKey() });
+ queryClient.removeQueries({ queryKey: trpc.kiloclaw.getMorningBriefingStatus.queryKey() });
+ };
+
// Wipe all instance-scoped caches so no stale data (e.g. gatewayReady
// from the old instance) bleeds into a subsequent re-provision flow.
// removeQueries drops the cached payload entirely; invalidateQueries
@@ -183,13 +188,25 @@ export function useKiloClawMutations() {
};
return {
- start: useMutation(trpc.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })),
+ start: useMutation(
+ trpc.kiloclaw.start.mutationOptions({
+ onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
+ await invalidateStatus();
+ },
+ })
+ ),
stop: useMutation(trpc.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })),
destroy: useMutation(
trpc.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState })
),
provision: useMutation(
- trpc.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatusAndBilling })
+ trpc.kiloclaw.provision.mutationOptions({
+ onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
+ await invalidateStatusAndBilling();
+ },
+ })
),
cycleInboundEmailAddress: useMutation(
trpc.kiloclaw.cycleInboundEmailAddress.mutationOptions({ onSuccess: invalidateStatus })
@@ -222,6 +239,7 @@ export function useKiloClawMutations() {
restartMachine: useMutation(
trpc.kiloclaw.restartMachine.mutationOptions({
onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),
@@ -232,6 +250,7 @@ export function useKiloClawMutations() {
restartOpenClaw: useMutation(
trpc.kiloclaw.restartOpenClaw.mutationOptions({
onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),
diff --git a/apps/web/src/hooks/useOrgKiloClaw.ts b/apps/web/src/hooks/useOrgKiloClaw.ts
index 4a5ce48e7..815338fb7 100644
--- a/apps/web/src/hooks/useOrgKiloClaw.ts
+++ b/apps/web/src/hooks/useOrgKiloClaw.ts
@@ -215,6 +215,15 @@ export function useOrgKiloClawMutations(
});
};
+ const clearGatewayAndMorningBriefingCaches = () => {
+ queryClient.removeQueries({
+ queryKey: trpc.organizations.kiloclaw.gatewayReady.queryKey({ organizationId }),
+ });
+ queryClient.removeQueries({
+ queryKey: trpc.organizations.kiloclaw.getMorningBriefingStatus.queryKey({ organizationId }),
+ });
+ };
+
// Helper: wrap a raw org mutation so mutate/mutateAsync inject organizationId.
// The `any` types are unavoidable here — we're wrapping tRPC mutations generically
// to pre-bind organizationId. The final return uses `satisfies` to catch missing keys.
@@ -237,7 +246,12 @@ export function useOrgKiloClawMutations(
/* eslint-enable @typescript-eslint/no-explicit-any */
const rawStart = useMutation(
- trpc.organizations.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })
+ trpc.organizations.kiloclaw.start.mutationOptions({
+ onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
+ await invalidateStatus();
+ },
+ })
);
const rawStop = useMutation(
trpc.organizations.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })
@@ -246,7 +260,12 @@ export function useOrgKiloClawMutations(
trpc.organizations.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState })
);
const rawProvision = useMutation(
- trpc.organizations.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatus })
+ trpc.organizations.kiloclaw.provision.mutationOptions({
+ onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
+ await invalidateStatus();
+ },
+ })
);
const rawCycleInboundEmailAddress = useMutation(
trpc.organizations.kiloclaw.cycleInboundEmailAddress.mutationOptions({
@@ -287,6 +306,7 @@ export function useOrgKiloClawMutations(
const rawRestartMachine = useMutation(
trpc.organizations.kiloclaw.restartMachine.mutationOptions({
onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }),
@@ -297,6 +317,7 @@ export function useOrgKiloClawMutations(
const rawRestartOpenClaw = useMutation(
trpc.organizations.kiloclaw.restartOpenClaw.mutationOptions({
onSuccess: async () => {
+ clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }),
diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts
index 90ac7a3ec..9931c0549 100644
--- a/apps/web/src/lib/kiloclaw/types.ts
+++ b/apps/web/src/lib/kiloclaw/types.ts
@@ -408,6 +408,30 @@ export type MorningBriefingSourceReadiness = {
summary: string;
};
+export type MorningBriefingDeliveryResult = {
+ channel: 'telegram' | 'discord' | 'slack';
+ status: 'sent' | 'skipped' | 'failed';
+ target?: string;
+ accountId?: string;
+ reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable';
+ error?: string;
+};
+
+export type MorningBriefingStatusLite = Pick<
+ MorningBriefingStatusResponse,
+ | 'enabled'
+ | 'desiredEnabled'
+ | 'observedEnabled'
+ | 'reconcileState'
+ | 'lastReconcileAction'
+ | 'code'
+ | 'cron'
+ | 'timezone'
+ | 'lastGeneratedDate'
+ | 'sourceReadiness'
+ | 'lastDelivery'
+>;
+
export type MorningBriefingStatusResponse = {
ok: boolean;
enabled?: boolean;
@@ -427,6 +451,7 @@ export type MorningBriefingStatusResponse = {
linear: MorningBriefingSourceReadiness;
web: MorningBriefingSourceReadiness;
};
+ lastDelivery?: MorningBriefingDeliveryResult[];
code?: string;
retryAfterSec?: number;
error?: string;
@@ -441,6 +466,7 @@ export type MorningBriefingActionResponse = {
date?: string;
filePath?: string;
failures?: string[];
+ delivery?: MorningBriefingDeliveryResult[];
code?: string;
retryAfterSec?: number;
error?: string;
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts
index 677b95f44..95c501705 100644
--- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildBriefingMarkdown,
+ formatBriefingMarkdownForMessage,
formatDateKey,
offsetDateKey,
resolveBriefingPath,
@@ -40,11 +41,67 @@ describe('briefing-utils', () => {
expect(markdown).toContain('- github: [ok] Fetched 3 issues');
expect(markdown).toContain('## GitHub');
expect(markdown).toContain('- Item 1');
- expect(markdown).toContain('## Failures / Skipped');
+ expect(markdown).toContain('## Failures');
+ });
+
+ it('omits failures section when there are no failures', () => {
+ const markdown = buildBriefingMarkdown({
+ dateKey: '2026-04-23',
+ generatedAt: new Date('2026-04-23T07:00:01Z'),
+ statuses: [
+ { source: 'github', configured: true, ok: true, summary: 'Fetched 1 issue' },
+ { source: 'linear', configured: true, ok: true, summary: 'Fetched 1 issue' },
+ { source: 'web', configured: true, ok: true, summary: 'Fetched 1 result' },
+ ],
+ sections: [{ title: 'GitHub', lines: ['- Item 1'] }],
+ failures: [],
+ });
+
+ expect(markdown).not.toContain('## Failures');
});
it('builds date-based file paths', () => {
const filePath = resolveBriefingPath('/tmp/briefings', '2026-04-23');
expect(filePath.endsWith('/briefings/2026-04-23.md')).toBe(true);
});
+
+ it('adapts briefing markdown into channel-friendly text', () => {
+ const markdown = [
+ '# Morning Briefing - 2026-04-23',
+ '',
+ '## GitHub',
+ '- [Fix flaky build](https://example.com/issue/1) (updated 2026-04-23)',
+ '',
+ '## Source Status',
+ '- github: [ok] Fetched 1 open issue',
+ '',
+ '_Generated at 2026-04-23T07:00:01.000Z_',
+ ].join('\n');
+
+ const message = formatBriefingMarkdownForMessage(markdown);
+
+ expect(message).toContain('Morning Briefing - 2026-04-23');
+ expect(message).toContain('GitHub');
+ expect(message).toContain(
+ '• Fix flaky build - https://example.com/issue/1 (updated 2026-04-23)'
+ );
+ expect(message).toContain('Generated at 2026-04-23T07:00:01.000Z');
+ expect(message).not.toContain('# ');
+ expect(message).not.toContain('[');
+ });
+
+ it('keeps markdown links with nested parentheses intact when adapting for messages', () => {
+ const markdown = [
+ '# Morning Briefing - 2026-04-23',
+ '',
+ '## Web Search',
+ '- [Spec page](https://example.com/wiki/Foo_(bar))',
+ '- [Another page](https://example.com/docs/(deep)/(nested))',
+ ].join('\n');
+
+ const message = formatBriefingMarkdownForMessage(markdown);
+
+ expect(message).toContain('• Spec page - https://example.com/wiki/Foo_(bar)');
+ expect(message).toContain('• Another page - https://example.com/docs/(deep)/(nested)');
+ });
});
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts
index f9db8dabc..3e1671a1c 100644
--- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts
@@ -69,7 +69,7 @@ export function buildBriefingMarkdown(params: {
if (params.failures.length > 0) {
lines.push('');
- lines.push('## Failures / Skipped');
+ lines.push('## Failures');
for (const failure of params.failures) {
lines.push(`- ${failure}`);
}
@@ -88,3 +88,90 @@ export function buildBriefingMarkdown(params: {
return lines.join('\n');
}
+
+function expandMarkdownLinks(line: string): string {
+ let result = '';
+ let i = 0;
+
+ while (i < line.length) {
+ const labelStart = line.indexOf('[', i);
+ if (labelStart < 0) {
+ result += line.slice(i);
+ break;
+ }
+
+ const labelEnd = line.indexOf(']', labelStart + 1);
+ if (labelEnd < 0 || line[labelEnd + 1] !== '(') {
+ result += line.slice(i, labelStart + 1);
+ i = labelStart + 1;
+ continue;
+ }
+
+ let urlEnd = labelEnd + 2;
+ let depth = 1;
+ while (urlEnd < line.length && depth > 0) {
+ const char = line[urlEnd];
+ if (char === '(') {
+ depth += 1;
+ } else if (char === ')') {
+ depth -= 1;
+ }
+ urlEnd += 1;
+ }
+
+ if (depth !== 0) {
+ result += line.slice(i, labelStart + 1);
+ i = labelStart + 1;
+ continue;
+ }
+
+ const label = line.slice(labelStart + 1, labelEnd);
+ const url = line.slice(labelEnd + 2, urlEnd - 1);
+
+ result += line.slice(i, labelStart);
+ result += `${label} - ${url}`;
+ i = urlEnd;
+ }
+
+ return result;
+}
+
+function convertInlineMarkdownToText(line: string): string {
+ const withLinksExpanded = expandMarkdownLinks(line);
+ return withLinksExpanded
+ .replace(/\[(ok|error|skipped)\]/gi, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/`([^`]+)`/g, '$1');
+}
+
+export function formatBriefingMarkdownForMessage(markdown: string): string {
+ const transformedLines = markdown.split(/\r?\n/).map(rawLine => {
+ const heading = /^#{1,2}\s+(.+)$/.exec(rawLine);
+ if (heading) {
+ return heading[1]?.trim() ?? '';
+ }
+
+ if (/^_.*_$/.test(rawLine.trim())) {
+ return rawLine.trim().slice(1, -1);
+ }
+
+ if (rawLine.startsWith('- ')) {
+ return `• ${convertInlineMarkdownToText(rawLine.slice(2))}`;
+ }
+
+ return convertInlineMarkdownToText(rawLine);
+ });
+
+ const compacted: string[] = [];
+ let previousBlank = false;
+ for (const line of transformedLines) {
+ const blank = line.trim().length === 0;
+ if (blank && previousBlank) {
+ continue;
+ }
+ compacted.push(line);
+ previousBlank = blank;
+ }
+
+ return compacted.join('\n').trim();
+}
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts
new file mode 100644
index 000000000..0bc599651
--- /dev/null
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts
@@ -0,0 +1,64 @@
+export type CommandCapableRuntime = {
+ runtime: {
+ system: {
+ runCommandWithTimeout: (
+ argv: string[],
+ options: { timeoutMs: number; cwd?: string }
+ ) => Promise<{ stdout: string; stderr: string; code: number | null }>;
+ };
+ };
+};
+
+export class CommandExecutionError extends Error {
+ readonly argv: string[];
+ readonly code: number | null;
+ readonly stdout: string;
+ readonly stderr: string;
+
+ constructor(params: { argv: string[]; code: number | null; stdout: string; stderr: string }) {
+ const detail = params.stderr.trim() || params.stdout.trim() || 'Command failed';
+ super(`${params.argv.join(' ')} failed: ${detail}`);
+ this.name = 'CommandExecutionError';
+ this.argv = params.argv;
+ this.code = params.code;
+ this.stdout = params.stdout;
+ this.stderr = params.stderr;
+ }
+}
+
+export function isTimeoutExecutionError(error: unknown): boolean {
+ if (!(error instanceof CommandExecutionError)) {
+ return false;
+ }
+
+ if (error.code === null) {
+ return true;
+ }
+
+ const text = `${error.stderr}\n${error.stdout}\n${error.message}`;
+ return (
+ text.includes('operation was aborted due to timeout') ||
+ text.includes('timed out') ||
+ text.includes('ETIMEDOUT') ||
+ text.includes('AbortError')
+ );
+}
+
+export async function runCommand(
+ api: CommandCapableRuntime,
+ argv: string[],
+ timeoutMs = 30_000
+): Promise<{ stdout: string; stderr: string }> {
+ const result = await api.runtime.system.runCommandWithTimeout(argv, {
+ timeoutMs,
+ });
+ if (result.code !== 0) {
+ throw new CommandExecutionError({
+ argv,
+ code: result.code,
+ stdout: result.stdout,
+ stderr: result.stderr,
+ });
+ }
+ return { stdout: result.stdout, stderr: result.stderr };
+}
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts
new file mode 100644
index 000000000..04847c8af
--- /dev/null
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts
@@ -0,0 +1,10 @@
+export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const;
+
+export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const;
+
+export const DELIVERY_REASONS = [
+ 'missing_target',
+ 'ambiguous_target',
+ 'send_failed',
+ 'config_unavailable',
+] as const;
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts
new file mode 100644
index 000000000..3452fafb1
--- /dev/null
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { parseStoredDelivery, resolveDeliveryRoute } from './delivery-utils';
+
+describe('delivery-utils', () => {
+ it('parseStoredDelivery ignores malformed entries and keeps valid ones', () => {
+ const parsed = parseStoredDelivery([
+ null,
+ 123,
+ { channel: 'telegram', status: 'sent', target: '-5055658641' },
+ { channel: 'discord', status: 'unknown' },
+ { channel: 'slack', status: 'failed', reason: 'send_failed', error: 'send failed' },
+ { channel: 'email', status: 'sent' },
+ { channel: 'telegram', status: 'skipped', reason: 'bogus_reason' },
+ ]);
+
+ expect(parsed).toEqual([
+ {
+ channel: 'telegram',
+ status: 'sent',
+ target: '-5055658641',
+ },
+ {
+ channel: 'slack',
+ status: 'failed',
+ reason: 'send_failed',
+ error: 'send failed',
+ },
+ {
+ channel: 'telegram',
+ status: 'skipped',
+ },
+ ]);
+ });
+
+ it('resolveDeliveryRoute infers single discord fallback channel target', () => {
+ const resolution = resolveDeliveryRoute({
+ channel: 'discord',
+ channelsConfig: {
+ discord: {
+ enabled: true,
+ guilds: {
+ 'guild-1': {
+ channels: {
+ '1234567890': { enabled: true },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ expect(resolution).toEqual({
+ configured: true,
+ route: {
+ channel: 'discord',
+ target: 'channel:1234567890',
+ },
+ });
+ });
+
+ it('resolveDeliveryRoute infers single slack fallback channel target', () => {
+ const resolution = resolveDeliveryRoute({
+ channel: 'slack',
+ channelsConfig: {
+ slack: {
+ enabled: true,
+ channels: {
+ C123456: { enabled: true },
+ },
+ },
+ },
+ });
+
+ expect(resolution).toEqual({
+ configured: true,
+ route: {
+ channel: 'slack',
+ target: 'channel:C123456',
+ },
+ });
+ });
+
+ it('resolveDeliveryRoute marks ambiguous fallback when multiple discord channels exist', () => {
+ const resolution = resolveDeliveryRoute({
+ channel: 'discord',
+ channelsConfig: {
+ discord: {
+ enabled: true,
+ guilds: {
+ 'guild-1': {
+ channels: {
+ '123': { enabled: true },
+ '456': { enabled: true },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ expect(resolution).toEqual({
+ configured: true,
+ route: null,
+ skipReason: 'ambiguous_target',
+ });
+ });
+});
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts
new file mode 100644
index 000000000..810771396
--- /dev/null
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts
@@ -0,0 +1,377 @@
+import { formatBriefingMarkdownForMessage } from './briefing-utils';
+import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils';
+import { DELIVERY_CHANNELS, DELIVERY_REASONS, DELIVERY_STATUSES } from './delivery-constants';
+
+export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number];
+
+export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
+
+export type DeliveryReason = (typeof DELIVERY_REASONS)[number];
+
+export type BriefingDeliveryResult = {
+ channel: DeliveryChannel;
+ status: DeliveryStatus;
+ target?: string;
+ accountId?: string;
+ reason?: DeliveryReason;
+ error?: string;
+};
+
+type DeliveryRoute = {
+ channel: DeliveryChannel;
+ target: string;
+ accountId?: string;
+};
+
+type DeliveryApi = CommandCapableRuntime & {
+ config: unknown;
+ logger: { info?: (message: string) => void; warn?: (message: string) => void };
+};
+
+type SkipReason = Extract;
+
+export type DeliveryRouteResolution = {
+ configured: boolean;
+ route: DeliveryRoute | null;
+ skipReason?: SkipReason;
+};
+
+function asObject(value: unknown): Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function normalizeDeliveryTarget(value: unknown): string | null {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+ return null;
+}
+
+function toEnabledObjectEntries(value: unknown): Array<[string, Record]> {
+ const record = asObject(value);
+ return Object.entries(record)
+ .filter((entry): entry is [string, Record] => {
+ const [key, raw] = entry;
+ if (key.trim() === '' || key === '*') {
+ return false;
+ }
+ return typeof raw === 'object' && raw !== null && !Array.isArray(raw);
+ })
+ .filter(([, raw]) => raw.enabled !== false);
+}
+
+function collectFallbackTargets(
+ channel: DeliveryChannel,
+ rawChannelConfig: Record
+): string[] {
+ if (channel === 'telegram') {
+ return toEnabledObjectEntries(rawChannelConfig.groups).map(([groupId]) => groupId);
+ }
+
+ if (channel === 'discord') {
+ const guildEntries = toEnabledObjectEntries(rawChannelConfig.guilds);
+ return guildEntries.flatMap(([, guildConfig]) => {
+ const channels = toEnabledObjectEntries(guildConfig.channels);
+ return channels.map(([channelId]) => `channel:${channelId}`);
+ });
+ }
+
+ const channels = toEnabledObjectEntries(rawChannelConfig.channels);
+ return channels.map(([channelId]) => `channel:${channelId}`);
+}
+
+export function resolveDeliveryRoute(params: {
+ channel: DeliveryChannel;
+ channelsConfig: Record;
+}): DeliveryRouteResolution {
+ const rawChannelConfig = asObject(params.channelsConfig[params.channel]);
+ if (Object.keys(rawChannelConfig).length === 0 || rawChannelConfig.enabled === false) {
+ return { configured: false, route: null };
+ }
+
+ const accountsConfig = asObject(rawChannelConfig.accounts);
+ const defaultAccount = asObject(accountsConfig.default);
+ const defaultAccountTarget = normalizeDeliveryTarget(defaultAccount.defaultTo);
+ if (defaultAccountTarget) {
+ return {
+ configured: true,
+ route: {
+ channel: params.channel,
+ target: defaultAccountTarget,
+ accountId: 'default',
+ },
+ };
+ }
+
+ const topLevelTarget = normalizeDeliveryTarget(rawChannelConfig.defaultTo);
+ if (topLevelTarget) {
+ return {
+ configured: true,
+ route: {
+ channel: params.channel,
+ target: topLevelTarget,
+ },
+ };
+ }
+
+ const fallbackTargets = collectFallbackTargets(params.channel, rawChannelConfig);
+ if (fallbackTargets.length === 1) {
+ return {
+ configured: true,
+ route: {
+ channel: params.channel,
+ target: fallbackTargets[0],
+ },
+ };
+ }
+
+ return {
+ configured: true,
+ route: null,
+ skipReason: fallbackTargets.length > 1 ? 'ambiguous_target' : 'missing_target',
+ };
+}
+
+function readChannelsConfigFromRuntimeConfig(config: unknown): Record | null {
+ const rawConfig = asObject(config);
+ if (!Object.prototype.hasOwnProperty.call(rawConfig, 'channels')) {
+ return null;
+ }
+ return asObject(rawConfig.channels);
+}
+
+async function readChannelsConfig(api: DeliveryApi): Promise> {
+ const fromRuntimeConfig = readChannelsConfigFromRuntimeConfig(api.config);
+ if (fromRuntimeConfig) {
+ return fromRuntimeConfig;
+ }
+
+ let lastError: unknown = null;
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ const { stdout } = await runCommand(
+ api,
+ ['openclaw', 'config', 'get', 'channels', '--json'],
+ 60_000
+ );
+ return asObject(JSON.parse(stdout));
+ } catch (error) {
+ const errorText = error instanceof Error ? error.message : String(error);
+ if (errorText.includes('Config path not found: channels')) {
+ return {};
+ }
+ lastError = error;
+ }
+ }
+
+ if (lastError) {
+ throw lastError;
+ }
+
+ return {};
+}
+
+function createSendCommand(params: { route: DeliveryRoute; messageText: string }): string[] {
+ const argv = [
+ 'openclaw',
+ 'message',
+ 'send',
+ '--channel',
+ params.route.channel,
+ '--target',
+ params.route.target,
+ '--message',
+ params.messageText,
+ ];
+ if (params.route.accountId) {
+ argv.push('--account', params.route.accountId);
+ }
+ return argv;
+}
+
+async function sendWithRetry(api: DeliveryApi, argv: string[]): Promise {
+ try {
+ await runCommand(api, argv, 120_000);
+ return;
+ } catch (error) {
+ if (!isTimeoutExecutionError(error)) {
+ throw error;
+ }
+ }
+ await runCommand(api, argv, 120_000);
+}
+
+function failedDeliveryForAllChannels(errorText: string): BriefingDeliveryResult[] {
+ return DELIVERY_CHANNELS.map(channel => ({
+ channel,
+ status: 'failed',
+ reason: 'config_unavailable',
+ error: errorText,
+ }));
+}
+
+function summarizeDeliveryError(error: unknown): string {
+ if (error instanceof Error) {
+ const message = error.message;
+ const openclawSendPrefix = 'openclaw message send';
+ if (message.startsWith(openclawSendPrefix) && message.includes(' failed: ')) {
+ const detail = message.slice(message.indexOf(' failed: ') + ' failed: '.length).trim();
+ if (detail.length > 0) {
+ return detail;
+ }
+ }
+ return message;
+ }
+
+ return String(error);
+}
+
+export function parseStoredDelivery(entries: unknown): BriefingDeliveryResult[] {
+ if (!Array.isArray(entries)) {
+ return [];
+ }
+
+ return entries
+ .map(entry => asObject(entry))
+ .map(entry => {
+ const channel =
+ entry.channel === 'telegram' || entry.channel === 'discord' || entry.channel === 'slack'
+ ? entry.channel
+ : null;
+ const status =
+ entry.status === 'sent' || entry.status === 'skipped' || entry.status === 'failed'
+ ? entry.status
+ : null;
+ if (!channel || !status) {
+ return null;
+ }
+
+ const reason =
+ entry.reason === 'missing_target' ||
+ entry.reason === 'ambiguous_target' ||
+ entry.reason === 'send_failed' ||
+ entry.reason === 'config_unavailable'
+ ? entry.reason
+ : undefined;
+
+ return {
+ channel,
+ status,
+ target: typeof entry.target === 'string' ? entry.target : undefined,
+ accountId: typeof entry.accountId === 'string' ? entry.accountId : undefined,
+ reason,
+ error: typeof entry.error === 'string' ? entry.error : undefined,
+ } satisfies BriefingDeliveryResult;
+ })
+ .filter((entry): entry is BriefingDeliveryResult => entry !== null);
+}
+
+export function formatDeliverySummary(delivery: BriefingDeliveryResult[]): string[] {
+ if (delivery.length === 0) {
+ return ['- delivery: no configured messaging channels found'];
+ }
+
+ return delivery.map(entry => {
+ const targetSuffix = entry.target ? ` (${entry.target})` : '';
+ if (entry.status === 'sent') {
+ return `- delivery: ${entry.channel} sent${targetSuffix}`;
+ }
+ if (entry.status === 'skipped') {
+ return `- delivery: ${entry.channel} skipped (${entry.reason ?? 'unknown'})`;
+ }
+ return `- delivery: ${entry.channel} failed${targetSuffix}${entry.error ? `: ${entry.error}` : ''}`;
+ });
+}
+
+export function logDeliveryOutcomeEvents(
+ api: Pick,
+ delivery: BriefingDeliveryResult[]
+): void {
+ for (const entry of delivery) {
+ const reason = entry.reason ?? 'none';
+ const target = entry.target ?? 'none';
+ const eventLine =
+ `event=morning_briefing_delivery_outcome` +
+ ` outcome=${entry.status}` +
+ ` channel=${entry.channel}` +
+ ` reason=${reason}` +
+ ` target=${target}`;
+ api.logger.info?.(eventLine);
+ if (entry.status === 'failed') {
+ const detail = entry.error ?? 'unknown_error';
+ api.logger.warn?.(
+ `event=morning_briefing_delivery_failure channel=${entry.channel} detail=${detail}`
+ );
+ }
+ }
+}
+
+export async function deliverBriefingToConfiguredChannels(
+ api: DeliveryApi,
+ markdown: string
+): Promise {
+ const messageText = formatBriefingMarkdownForMessage(markdown);
+ if (!messageText) {
+ return [];
+ }
+
+ let channelsConfig: Record;
+ try {
+ channelsConfig = await readChannelsConfig(api);
+ } catch (error) {
+ const errorText = error instanceof Error ? error.message : String(error);
+ api.logger.warn?.(`Morning briefing delivery config read failed: ${errorText}`);
+ return failedDeliveryForAllChannels(errorText);
+ }
+
+ const delivery: BriefingDeliveryResult[] = [];
+ const routes: DeliveryRoute[] = [];
+
+ for (const channel of DELIVERY_CHANNELS) {
+ const resolution = resolveDeliveryRoute({ channel, channelsConfig });
+ if (!resolution.configured) {
+ continue;
+ }
+
+ if (!resolution.route) {
+ delivery.push({
+ channel,
+ status: 'skipped',
+ reason: resolution.skipReason ?? 'missing_target',
+ });
+ continue;
+ }
+
+ routes.push(resolution.route);
+ }
+
+ for (const route of routes) {
+ const argv = createSendCommand({ route, messageText });
+ try {
+ await sendWithRetry(api, argv);
+ delivery.push({
+ channel: route.channel,
+ status: 'sent',
+ target: route.target,
+ accountId: route.accountId,
+ });
+ } catch (error) {
+ delivery.push({
+ channel: route.channel,
+ status: 'failed',
+ reason: 'send_failed',
+ target: route.target,
+ accountId: route.accountId,
+ error: summarizeDeliveryError(error),
+ });
+ }
+ }
+
+ return delivery;
+}
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts
index 8d671dd72..ffe91ed67 100644
--- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts
@@ -33,7 +33,16 @@ type TestHarness = {
commandHandler: (ctx: { args?: string }) => Promise<{ text: string }>;
statusHttpHandler: (_req: unknown, res: FakeResponse) => Promise;
enableHttpHandler: (req: unknown, res: FakeResponse) => Promise;
+ runHttpHandler: (_req: unknown, res: FakeResponse) => Promise;
cronJobs: CronJob[];
+ sentMessages: Array<{
+ channel: string;
+ target: string;
+ accountId?: string;
+ message: string;
+ }>;
+ loggerInfo: ReturnType;
+ loggerWarn: ReturnType;
runCommandWithTimeout: ReturnType;
};
@@ -63,6 +72,12 @@ async function createHarness(options?: {
disableCommandFails?: boolean;
preloadedConfig?: Record;
preloadedStatus?: Record;
+ githubAuthReady?: boolean;
+ githubIssues?: Array<{ title: string; url: string; updatedAt?: string }>;
+ channelsConfig?: Record;
+ messageSendFailures?: Partial>;
+ messageSendFailureCounts?: Partial>;
+ omitRuntimeChannelsConfig?: boolean;
}): Promise {
const { default: morningBriefingPlugin } = await import('./index');
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morning-briefing-'));
@@ -86,11 +101,81 @@ async function createHarness(options?: {
let sequence = 0;
const cronJobs: CronJob[] = [];
+ const sentMessages: Array<{
+ channel: string;
+ target: string;
+ accountId?: string;
+ message: string;
+ }> = [];
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv[0] === 'gh' && argv[1] === 'auth' && argv[2] === 'status') {
+ if (options?.githubAuthReady) {
+ return { stdout: '', stderr: '', code: 0 };
+ }
return { stdout: '', stderr: 'not authenticated', code: 1 };
}
+ if (argv[0] === 'gh' && argv[1] === 'search' && argv[2] === 'issues') {
+ return {
+ stdout: JSON.stringify(options?.githubIssues ?? []),
+ stderr: '',
+ code: 0,
+ };
+ }
+
+ if (
+ argv[0] === 'openclaw' &&
+ argv[1] === 'config' &&
+ argv[2] === 'get' &&
+ argv[3] === 'channels'
+ ) {
+ if (!options?.channelsConfig) {
+ return {
+ stdout: '',
+ stderr: 'Config path not found: channels',
+ code: 1,
+ };
+ }
+ return {
+ stdout: JSON.stringify(options.channelsConfig),
+ stderr: '',
+ code: 0,
+ };
+ }
+
+ if (argv[0] === 'openclaw' && argv[1] === 'message' && argv[2] === 'send') {
+ const channelIndex = argv.indexOf('--channel');
+ const targetIndex = argv.indexOf('--target');
+ const messageIndex = argv.indexOf('--message');
+ const accountIndex = argv.indexOf('--account');
+ const channel = channelIndex >= 0 ? (argv[channelIndex + 1] ?? '') : '';
+ const target = targetIndex >= 0 ? (argv[targetIndex + 1] ?? '') : '';
+ const message = messageIndex >= 0 ? (argv[messageIndex + 1] ?? '') : '';
+ const accountId = accountIndex >= 0 ? argv[accountIndex + 1] : undefined;
+ if (channel && target && message) {
+ sentMessages.push({ channel, target, accountId, message });
+ }
+ const configuredFailure =
+ channel === 'telegram' || channel === 'discord' || channel === 'slack'
+ ? options?.messageSendFailures?.[channel]
+ : undefined;
+ const configuredFailureCount =
+ channel === 'telegram' || channel === 'discord' || channel === 'slack'
+ ? options?.messageSendFailureCounts?.[channel]
+ : undefined;
+ if (configuredFailure && configuredFailureCount && configuredFailureCount > 0) {
+ if (!options?.messageSendFailureCounts) {
+ return { stdout: '', stderr: configuredFailure, code: 1 };
+ }
+ options.messageSendFailureCounts[channel] = configuredFailureCount - 1;
+ return { stdout: '', stderr: configuredFailure, code: 1 };
+ }
+ if (configuredFailure && configuredFailureCount === undefined) {
+ return { stdout: '', stderr: configuredFailure, code: 1 };
+ }
+ return { stdout: JSON.stringify({ ok: true }), stderr: '', code: 0 };
+ }
+
if (argv[0] === 'openclaw' && argv[1] === 'cron') {
const subcommand = argv[2];
@@ -156,6 +241,9 @@ async function createHarness(options?: {
let commandHandler: ((ctx: { args?: string }) => Promise<{ text: string }>) | null = null;
let statusHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null;
let enableHttpHandler: ((req: unknown, res: FakeResponse) => Promise) | null = null;
+ let runHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null;
+ const loggerInfo = vi.fn();
+ const loggerWarn = vi.fn();
morningBriefingPlugin.register({
runtime: {
@@ -168,8 +256,9 @@ async function createHarness(options?: {
},
config: {
agents: { defaults: { userTimezone: 'America/Chicago' } },
+ ...(options?.omitRuntimeChannelsConfig ? {} : { channels: options?.channelsConfig ?? {} }),
},
- logger: { warn: vi.fn() },
+ logger: { info: loggerInfo, warn: loggerWarn },
registerCommand: (def: { handler: (ctx: { args?: string }) => Promise<{ text: string }> }) => {
commandHandler = def.handler;
},
@@ -181,13 +270,15 @@ async function createHarness(options?: {
statusHttpHandler = route.handler;
} else if (route.path.endsWith('/enable')) {
enableHttpHandler = route.handler;
+ } else if (route.path.endsWith('/run')) {
+ runHttpHandler = route.handler;
}
},
registerTool: vi.fn(),
on: vi.fn(),
} as never);
- if (!commandHandler || !statusHttpHandler || !enableHttpHandler) {
+ if (!commandHandler || !statusHttpHandler || !enableHttpHandler || !runHttpHandler) {
throw new Error('Failed to register command or HTTP handlers');
}
@@ -196,7 +287,11 @@ async function createHarness(options?: {
commandHandler,
statusHttpHandler,
enableHttpHandler,
+ runHttpHandler,
cronJobs,
+ sentMessages,
+ loggerInfo,
+ loggerWarn,
runCommandWithTimeout,
};
}
@@ -431,4 +526,420 @@ describe('morning briefing lifecycle', () => {
const config = await readJson(path.join(harness.stateDir, 'morning-briefing', 'config.json'));
expect(config.timezone).toBe('UTC');
});
+
+ it('sends adapted briefing message to configured channel targets and persists delivery metadata', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Fix failing deploy workflow',
+ url: 'https://github.com/Kilo-Org/cloud/issues/123',
+ updatedAt: '2026-04-24T10:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ defaultTo: '-100123456',
+ },
+ discord: {
+ enabled: true,
+ accounts: {
+ default: {
+ defaultTo: 'channel:1234567890',
+ },
+ },
+ },
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; target?: string; accountId?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-100123456' }),
+ expect.objectContaining({
+ channel: 'discord',
+ status: 'sent',
+ target: 'channel:1234567890',
+ accountId: 'default',
+ }),
+ ])
+ );
+
+ expect(harness.sentMessages).toHaveLength(2);
+ for (const sent of harness.sentMessages) {
+ expect(sent.message).toContain('Morning Briefing -');
+ expect(sent.message).toContain('GitHub');
+ expect(sent.message).toContain('• ');
+ expect(sent.message).not.toContain('# ');
+ expect(sent.message).toContain('https://github.com/Kilo-Org/cloud/issues/123');
+ expect(sent.message).not.toContain('Repository:');
+ }
+
+ const statusPayload = new FakeResponse();
+ await harness.statusHttpHandler({}, statusPayload);
+ const statusBody = JSON.parse(statusPayload.body) as {
+ ok: boolean;
+ lastDelivery?: Array<{ channel: string; status: string }>;
+ };
+ expect(statusBody.ok).toBe(true);
+ expect(statusBody.lastDelivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channel: 'telegram', status: 'sent' }),
+ expect.objectContaining({ channel: 'discord', status: 'sent' }),
+ ])
+ );
+ });
+
+ it('marks missing default targets as skipped and send errors as failed without failing run', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Investigate queue latency',
+ url: 'https://github.com/Kilo-Org/cloud/issues/456',
+ updatedAt: '2026-04-24T12:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ },
+ slack: {
+ enabled: true,
+ defaultTo: 'channel:C123',
+ },
+ },
+ messageSendFailures: {
+ slack: 'slack send failed',
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; reason?: string; error?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ channel: 'telegram',
+ status: 'skipped',
+ reason: 'missing_target',
+ }),
+ expect.objectContaining({
+ channel: 'slack',
+ status: 'failed',
+ reason: 'send_failed',
+ }),
+ ])
+ );
+ const slackFailure = payload.delivery?.find(entry => entry.channel === 'slack');
+ expect(slackFailure?.error).toBe('slack send failed');
+ });
+
+ it('uses single configured telegram group as fallback target when defaultTo is missing', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Review release checklist',
+ url: 'https://github.com/Kilo-Org/cloud/issues/789',
+ updatedAt: '2026-04-24T13:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ groups: {
+ '-5055658641': {
+ requireMention: false,
+ },
+ },
+ },
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; target?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ channel: 'telegram',
+ status: 'sent',
+ target: '-5055658641',
+ }),
+ ])
+ );
+ });
+
+ it('skips with ambiguous_target when multiple fallback destinations are available', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Investigate flaky integration test',
+ url: 'https://github.com/Kilo-Org/cloud/issues/790',
+ updatedAt: '2026-04-24T14:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ groups: {
+ '-5055658641': {
+ requireMention: false,
+ },
+ '-5055658642': {
+ requireMention: false,
+ },
+ },
+ },
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; reason?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ channel: 'telegram',
+ status: 'skipped',
+ reason: 'ambiguous_target',
+ }),
+ ])
+ );
+ expect(harness.sentMessages).toHaveLength(0);
+ });
+
+ it('uses runtime config channels for delivery without shelling out', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Confirm runtime config path',
+ url: 'https://github.com/Kilo-Org/cloud/issues/800',
+ updatedAt: '2026-04-24T15:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ groups: {
+ '-5055658641': {
+ requireMention: false,
+ },
+ },
+ },
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; target?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }),
+ ])
+ );
+ expect(
+ harness.runCommandWithTimeout.mock.calls.some(
+ call =>
+ Array.isArray(call[0]) &&
+ call[0][0] === 'openclaw' &&
+ call[0][1] === 'config' &&
+ call[0][2] === 'get' &&
+ call[0][3] === 'channels'
+ )
+ ).toBe(false);
+ });
+
+ it('falls back to CLI channel config when runtime channels are unavailable', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Confirm CLI fallback path',
+ url: 'https://github.com/Kilo-Org/cloud/issues/801',
+ updatedAt: '2026-04-24T15:10:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ groups: {
+ '-5055658641': {
+ requireMention: false,
+ },
+ },
+ },
+ },
+ omitRuntimeChannelsConfig: true,
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string; target?: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }),
+ ])
+ );
+ expect(
+ harness.runCommandWithTimeout.mock.calls.some(
+ call =>
+ Array.isArray(call[0]) &&
+ call[0][0] === 'openclaw' &&
+ call[0][1] === 'config' &&
+ call[0][2] === 'get' &&
+ call[0][3] === 'channels'
+ )
+ ).toBe(true);
+ });
+
+ it('retries timed-out delivery once before marking send_failed', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Retry flaky channel send',
+ url: 'https://github.com/Kilo-Org/cloud/issues/900',
+ updatedAt: '2026-04-25T00:00:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ defaultTo: '-5055658641',
+ },
+ },
+ messageSendFailures: {
+ telegram: 'The operation was aborted due to timeout',
+ },
+ messageSendFailureCounts: {
+ telegram: 1,
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const payload = JSON.parse(response.body) as {
+ ok: boolean;
+ delivery?: Array<{ channel: string; status: string }>;
+ };
+ expect(payload.ok).toBe(true);
+ expect(payload.delivery).toEqual(
+ expect.arrayContaining([expect.objectContaining({ channel: 'telegram', status: 'sent' })])
+ );
+
+ const sendCalls = harness.runCommandWithTimeout.mock.calls.filter(
+ call =>
+ Array.isArray(call[0]) &&
+ call[0][0] === 'openclaw' &&
+ call[0][1] === 'message' &&
+ call[0][2] === 'send'
+ );
+ expect(sendCalls).toHaveLength(2);
+ expect(sendCalls[0]?.[1]).toMatchObject({ timeoutMs: 120_000 });
+ expect(sendCalls[1]?.[1]).toMatchObject({ timeoutMs: 120_000 });
+ });
+
+ it('emits delivery outcome metric logs for sent/skipped/failed results', async () => {
+ const harness = await createHarness({
+ githubAuthReady: true,
+ githubIssues: [
+ {
+ title: 'Delivery observability smoke check',
+ url: 'https://github.com/Kilo-Org/cloud/issues/910',
+ updatedAt: '2026-04-25T00:10:00Z',
+ },
+ ],
+ channelsConfig: {
+ telegram: {
+ enabled: true,
+ defaultTo: '-5055658641',
+ },
+ discord: {
+ enabled: true,
+ },
+ slack: {
+ enabled: true,
+ defaultTo: 'channel:C123',
+ },
+ },
+ messageSendFailures: {
+ slack: 'slack send failed',
+ },
+ });
+
+ const response = new FakeResponse();
+ await harness.runHttpHandler({}, response);
+ expect(response.statusCode).toBe(200);
+
+ const infoMessages = harness.loggerInfo.mock.calls.map(call => String(call[0]));
+ expect(
+ infoMessages.some(message =>
+ message.includes('event=morning_briefing_delivery_outcome outcome=sent channel=telegram')
+ )
+ ).toBe(true);
+ expect(
+ infoMessages.some(message =>
+ message.includes('event=morning_briefing_delivery_outcome outcome=skipped channel=discord')
+ )
+ ).toBe(true);
+ expect(
+ infoMessages.some(message =>
+ message.includes('event=morning_briefing_delivery_outcome outcome=failed channel=slack')
+ )
+ ).toBe(true);
+
+ const warnMessages = harness.loggerWarn.mock.calls.map(call => String(call[0]));
+ expect(
+ warnMessages.some(message =>
+ message.includes(
+ 'event=morning_briefing_delivery_failure channel=slack detail=slack send failed'
+ )
+ )
+ ).toBe(true);
+ });
});
diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts
index cb1bc30a8..4c1fed24d 100644
--- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts
+++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts
@@ -3,6 +3,15 @@ import path from 'node:path';
import { Type } from '@sinclair/typebox';
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils';
+import {
+ type BriefingDeliveryResult,
+ deliverBriefingToConfiguredChannels,
+ formatDeliverySummary,
+ logDeliveryOutcomeEvents,
+ parseStoredDelivery,
+} from './delivery-utils';
+import { DELIVERY_CHANNELS } from './delivery-constants';
+import { CommandExecutionError, runCommand } from './command-utils';
import {
filterEnabledBriefingJobs,
pickCanonicalCronJobId,
@@ -41,6 +50,7 @@ type StoredStatus = {
lastPath: string | null;
sourceSummary: Array<{ source: string; configured: boolean; ok: boolean; summary: string }>;
failures: string[];
+ lastDelivery: BriefingDeliveryResult[];
observedEnabled: boolean | null;
reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed';
lastReconcileAt: string | null;
@@ -131,30 +141,6 @@ async function writeJsonFile(filePath: string, value: unknown): Promise {
await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
}
-async function runCommand(
- api: {
- runtime: {
- system: {
- runCommandWithTimeout: (
- argv: string[],
- options: { timeoutMs: number; cwd?: string }
- ) => Promise<{ stdout: string; stderr: string; code: number | null }>;
- };
- };
- },
- argv: string[],
- timeoutMs = 30_000
-): Promise<{ stdout: string; stderr: string }> {
- const result = await api.runtime.system.runCommandWithTimeout(argv, {
- timeoutMs,
- });
- if (result.code !== 0) {
- const message = result.stderr.trim() || result.stdout.trim() || 'Command failed';
- throw new Error(`${argv.join(' ')} failed: ${message}`);
- }
- return { stdout: result.stdout, stderr: result.stderr };
-}
-
async function runCronJson(
api: {
runtime: {
@@ -169,11 +155,24 @@ async function runCronJson(
argv: string[]
): Promise> {
const [subcommand = ''] = argv;
- const jsonUnsupported = subcommand === 'disable';
+ const jsonUnsupported = subcommand === 'disable' || subcommand === 'edit';
const command = jsonUnsupported
? ['openclaw', 'cron', ...argv]
: ['openclaw', 'cron', ...argv, '--json'];
- const { stdout } = await runCommand(api, command, 60_000);
+ let stdout: string;
+ try {
+ ({ stdout } = await runCommand(api, command, 60_000));
+ } catch (error) {
+ if (
+ !jsonUnsupported &&
+ error instanceof CommandExecutionError &&
+ error.stderr.includes("unknown option '--json'")
+ ) {
+ ({ stdout } = await runCommand(api, ['openclaw', 'cron', ...argv], 60_000));
+ } else {
+ throw error;
+ }
+ }
try {
return asObject(JSON.parse(stdout));
} catch {
@@ -228,7 +227,7 @@ async function removeDuplicateBriefingCronJobs(
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
};
};
- logger: { warn?: (message: string) => void };
+ logger: { info?: (message: string) => void; warn?: (message: string) => void };
},
canonicalId: string
): Promise {
@@ -268,7 +267,7 @@ function resolveDefaults(api: {
}
function resolveEffectiveTimezone(
- api: { logger: { warn?: (message: string) => void } },
+ api: { logger: { info?: (message: string) => void; warn?: (message: string) => void } },
timezone: string,
context: 'enable' | 'schedule' | 'date'
): string {
@@ -329,6 +328,7 @@ async function readStoredStatus(paths: { statusPath: string }): Promise typeof value === 'string')
: [],
+ lastDelivery: parseStoredDelivery(existing.lastDelivery),
observedEnabled:
typeof existing.observedEnabled === 'boolean' ? existing.observedEnabled : null,
reconcileState:
@@ -408,7 +409,7 @@ async function ensureCronJob(
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
};
};
- logger: { warn?: (message: string) => void };
+ logger: { info?: (message: string) => void; warn?: (message: string) => void };
},
config: StoredConfig
): Promise<{ cronJobId: string; cron: string; timezone: string }> {
@@ -669,13 +670,7 @@ async function collectWebSearch(api: {
configured: true,
ok: true,
summary: `Fetched ${results.length} web results (${response.provider})`,
- sectionLines: results
- .slice(0, 6)
- .map(item =>
- item.summary.length > 0
- ? `- [${item.title}](${item.url}) - ${item.summary}`
- : `- [${item.title}](${item.url})`
- ),
+ sectionLines: results.slice(0, 6).map(item => `- [${item.title}](${item.url})`),
};
} catch (error) {
return {
@@ -793,6 +788,7 @@ async function generateBriefing(
};
};
config: unknown;
+ logger: { info?: (message: string) => void; warn?: (message: string) => void };
},
dateKey: string
): Promise<{
@@ -801,6 +797,7 @@ async function generateBriefing(
markdown: string;
sources: SourceCollectionResult[];
failures: string[];
+ delivery: BriefingDeliveryResult[];
}> {
const paths = getStatePaths(api);
await ensureStorage(paths);
@@ -841,6 +838,20 @@ async function generateBriefing(
const filePath = resolveBriefingPath(paths.briefingsDir, dateKey);
await fs.writeFile(filePath, markdown, 'utf8');
+ let delivery: BriefingDeliveryResult[];
+ try {
+ delivery = await deliverBriefingToConfiguredChannels(api, markdown);
+ } catch (error) {
+ const errorText = error instanceof Error ? error.message : String(error);
+ api.logger.warn?.(`Morning briefing delivery failed unexpectedly: ${errorText}`);
+ delivery = DELIVERY_CHANNELS.map(channel => ({
+ channel,
+ status: 'failed',
+ reason: 'config_unavailable',
+ error: errorText,
+ }));
+ }
+ logDeliveryOutcomeEvents(api, delivery);
await patchStoredStatus(paths, {
lastGeneratedDate: dateKey,
@@ -853,6 +864,7 @@ async function generateBriefing(
summary: source.summary,
})),
failures,
+ lastDelivery: delivery,
});
return {
@@ -861,6 +873,7 @@ async function generateBriefing(
markdown,
sources,
failures,
+ delivery,
};
}
@@ -900,7 +913,7 @@ async function resolveDateKeyForOffset(
};
};
pluginConfig?: Record;
- logger: { warn?: (message: string) => void };
+ logger: { info?: (message: string) => void; warn?: (message: string) => void };
},
offset: number
): Promise {
@@ -944,6 +957,7 @@ async function getStatusSnapshot(api: {
linear: { configured: boolean; summary: string };
web: { configured: boolean; summary: string };
};
+ lastDelivery: BriefingDeliveryResult[];
reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed';
lastReconcileAt: string | null;
lastReconcileError: string | null;
@@ -971,6 +985,7 @@ async function getStatusSnapshot(api: {
linear,
web,
},
+ lastDelivery: status.lastDelivery,
reconcileState: status.reconcileState,
lastReconcileAt: status.lastReconcileAt,
lastReconcileError: status.lastReconcileError,
@@ -1218,6 +1233,7 @@ export default definePluginEntry({
`Generated briefing for ${result.dateKey}.`,
`- file: ${result.filePath}`,
...result.failures.map(failure => `- note: ${failure}`),
+ ...formatDeliverySummary(result.delivery),
].join('\n');
}
@@ -1322,6 +1338,7 @@ export default definePluginEntry({
`Morning briefing generated for ${result.dateKey}.`,
`Saved to ${result.filePath}.`,
...result.failures.map(failure => `Note: ${failure}`),
+ ...formatDeliverySummary(result.delivery).map(line => line.replace(/^- /, '')),
].join('\n'),
},
],
@@ -1468,6 +1485,7 @@ export default definePluginEntry({
date: result.dateKey,
filePath: result.filePath,
failures: result.failures,
+ delivery: result.delivery,
});
} catch (error) {
sendJson(res, 500, {
diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts
index e1665a73e..74eb6c496 100644
--- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts
+++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts
@@ -1,4 +1,9 @@
import { z, type ZodType } from 'zod';
+import {
+ DELIVERY_CHANNELS,
+ DELIVERY_REASONS,
+ DELIVERY_STATUSES,
+} from '../../plugins/kiloclaw-morning-briefing/src/delivery-constants';
export type GatewayProcessStatus = {
state: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'shutting_down';
@@ -104,6 +109,15 @@ const MorningBriefingSourceReadinessSchema = z.object({
summary: z.string(),
});
+const MorningBriefingDeliverySchema = z.object({
+ channel: z.enum(DELIVERY_CHANNELS),
+ status: z.enum(DELIVERY_STATUSES),
+ target: z.string().optional(),
+ accountId: z.string().optional(),
+ reason: z.enum(DELIVERY_REASONS).optional(),
+ error: z.string().optional(),
+});
+
export const MorningBriefingStatusResponseSchema = z.object({
ok: z.boolean(),
enabled: z.boolean().optional(),
@@ -125,6 +139,7 @@ export const MorningBriefingStatusResponseSchema = z.object({
web: MorningBriefingSourceReadinessSchema,
})
.optional(),
+ lastDelivery: z.array(MorningBriefingDeliverySchema).optional(),
code: z.string().optional(),
retryAfterSec: z.number().int().positive().optional(),
error: z.string().optional(),
@@ -139,6 +154,7 @@ export const MorningBriefingActionResponseSchema = z.object({
date: z.string().optional(),
filePath: z.string().optional(),
failures: z.array(z.string()).optional(),
+ delivery: z.array(MorningBriefingDeliverySchema).optional(),
code: z.string().optional(),
retryAfterSec: z.number().int().positive().optional(),
error: z.string().optional(),
diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts
index d3b794bd6..ccd206fa0 100644
--- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts
+++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { deriveGatewayToken } from '../../auth/gateway-token';
import { createMutableState } from './state';
-import { getGatewayProcessStatus, getMorningBriefingStatus, waitForHealthy } from './gateway';
+import {
+ getGatewayProcessStatus,
+ getMorningBriefingStatus,
+ runMorningBriefing,
+ waitForHealthy,
+} from './gateway';
import { GatewayControllerError } from '../gateway-controller-types';
type FetchMock = ReturnType<
@@ -136,7 +141,6 @@ describe('gateway controller routing', () => {
expect(result).toEqual({
ok: true,
- enabled: false,
reconcileState: 'in_progress',
error: 'Gateway warming up, retrying shortly.',
code: 'gateway_warming_up',
@@ -166,4 +170,48 @@ describe('gateway controller routing', () => {
} as never)
).rejects.toBeInstanceOf(GatewayControllerError);
});
+
+ it('accepts run response with delivery metadata', async () => {
+ const state = createMutableState();
+ state.provider = 'fly';
+ state.sandboxId = 'sandbox-1';
+ state.flyAppName = 'test-app';
+ state.flyMachineId = 'machine-1';
+
+ const fetchMock: FetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ ok: true,
+ date: '2026-04-24',
+ filePath: '/tmp/morning-briefing/2026-04-24.md',
+ failures: [],
+ delivery: [
+ { channel: 'telegram', status: 'sent', target: '-100123' },
+ { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }
+ )
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
+
+ const result = await runMorningBriefing(state, {
+ GATEWAY_TOKEN_SECRET: 'gateway-secret',
+ FLY_APP_NAME: 'fallback-app',
+ } as never);
+
+ expect(result).toMatchObject({
+ ok: true,
+ date: '2026-04-24',
+ delivery: [
+ { channel: 'telegram', status: 'sent', target: '-100123' },
+ { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' },
+ ],
+ });
+ expect(timeoutSpy).toHaveBeenCalledWith(120_000);
+ });
});
diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts
index e0de463de..164d8208a 100644
--- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts
+++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts
@@ -509,7 +509,6 @@ export async function getMorningBriefingStatus(
if (isMorningBriefingWarmupControllerError(error)) {
return {
ok: true,
- enabled: false,
reconcileState: 'in_progress',
error: 'Gateway warming up, retrying shortly.',
code: 'gateway_warming_up',
@@ -572,7 +571,8 @@ export async function runMorningBriefing(
'/_kilo/morning-briefing/run',
'POST',
MorningBriefingActionResponseSchema,
- {}
+ {},
+ { timeoutMs: 120_000 }
);
} catch (error) {
if (isErrorUnknownRoute(error)) return null;
diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts
index 5b1a31c78..86469cf81 100644
--- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts
+++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts
@@ -121,4 +121,119 @@ describe('platform morning-briefing warm-up handling', () => {
expect(response.status).toBe(500);
expect(enableMorningBriefing).toHaveBeenCalledTimes(1);
});
+
+ it('returns delivery metadata from run endpoint', async () => {
+ const runMorningBriefing = vi.fn<() => Promise>().mockResolvedValue({
+ ok: true,
+ date: '2026-04-24',
+ filePath: '/tmp/morning-briefing/2026-04-24.md',
+ failures: [],
+ delivery: [
+ { channel: 'telegram', status: 'sent', target: '-100123' },
+ { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' },
+ ],
+ });
+ const env = baseEnv({ runMorningBriefing });
+
+ const response = await platform.request(
+ '/morning-briefing/run',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ userId: 'user-1' }),
+ },
+ env
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ ok: true,
+ date: '2026-04-24',
+ delivery: [
+ { channel: 'telegram', status: 'sent', target: '-100123' },
+ { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' },
+ ],
+ });
+ });
+
+ it('retries run when gateway is warming up and then succeeds', async () => {
+ const runMorningBriefing = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(new Error('Failed to reach gateway'))
+ .mockResolvedValueOnce({
+ ok: true,
+ date: '2026-04-24',
+ filePath: '/tmp/morning-briefing/2026-04-24.md',
+ failures: [],
+ });
+ const env = baseEnv({ runMorningBriefing });
+
+ const requestPromise = platform.request(
+ '/morning-briefing/run',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ userId: 'user-1' }),
+ },
+ env
+ );
+
+ await vi.runAllTimersAsync();
+ const response = await requestPromise;
+
+ expect(response.status).toBe(200);
+ expect(runMorningBriefing).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not retry run when timeout occurs and returns dedicated timeout code', async () => {
+ const runMorningBriefing = vi
+ .fn<() => Promise>()
+ .mockRejectedValue(
+ new Error('Gateway controller request failed: The operation was aborted due to timeout')
+ );
+ const env = baseEnv({ runMorningBriefing });
+
+ const response = await platform.request(
+ '/morning-briefing/run',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ userId: 'user-1' }),
+ },
+ env
+ );
+
+ expect(response.status).toBe(504);
+ expect(await response.json()).toMatchObject({
+ code: 'morning_briefing_run_timeout',
+ });
+ expect(runMorningBriefing).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns timeout code for run timeout instead of generic 500', async () => {
+ const runMorningBriefing = vi
+ .fn<() => Promise>()
+ .mockRejectedValue(
+ new Error('Gateway controller request failed: The operation was aborted due to timeout')
+ );
+ const env = baseEnv({ runMorningBriefing });
+
+ const requestPromise = platform.request(
+ '/morning-briefing/run',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ userId: 'user-1' }),
+ },
+ env
+ );
+
+ await vi.runAllTimersAsync();
+ const response = await requestPromise;
+
+ expect(response.status).toBe(504);
+ expect(await response.json()).toMatchObject({
+ code: 'morning_briefing_run_timeout',
+ });
+ });
});
diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts
index 974a6d92d..5eb755baf 100644
--- a/services/kiloclaw/src/routes/platform.ts
+++ b/services/kiloclaw/src/routes/platform.ts
@@ -1917,21 +1917,39 @@ const MorningBriefingSetupSchema = z.object({
timezone: z.string().min(1).optional(),
});
-function isMorningBriefingWarmupError(err: unknown): boolean {
+type MorningBriefingWarmupRetryPolicy = {
+ includeTimeout: boolean;
+};
+
+function isMorningBriefingTimeoutError(err: unknown): boolean {
const raw = err instanceof Error ? err.message : String(err);
const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, '');
- return (
+ return normalized.includes('operation was aborted due to timeout');
+}
+
+function isMorningBriefingWarmupError(
+ err: unknown,
+ policy: MorningBriefingWarmupRetryPolicy = { includeTimeout: true }
+): boolean {
+ const raw = err instanceof Error ? err.message : String(err);
+ const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, '');
+ if (
normalized.includes('Gateway not running') ||
- normalized.includes('Failed to reach gateway') ||
- normalized.includes('operation was aborted due to timeout')
- );
+ normalized.includes('Failed to reach gateway')
+ ) {
+ return true;
+ }
+ return policy.includeTimeout && isMorningBriefingTimeoutError(err);
}
async function sleep(ms: number): Promise {
await new Promise(resolve => setTimeout(resolve, ms));
}
-async function withMorningBriefingWarmupRetry(operation: () => Promise): Promise {
+async function withMorningBriefingWarmupRetry(
+ operation: () => Promise,
+ policy: MorningBriefingWarmupRetryPolicy = { includeTimeout: true }
+): Promise {
const delaysMs = [0, 750, 1500];
let lastError: unknown = null;
@@ -1944,7 +1962,7 @@ async function withMorningBriefingWarmupRetry(operation: () => Promise): P
return await operation();
} catch (err) {
lastError = err;
- if (!isMorningBriefingWarmupError(err)) {
+ if (!isMorningBriefingWarmupError(err, policy)) {
throw err;
}
}
@@ -1984,7 +2002,6 @@ platform.get('/morning-briefing/status', async c => {
return c.json(
{
ok: true,
- enabled: false,
reconcileState: 'in_progress',
error: 'Gateway warming up, retrying shortly.',
code: 'gateway_warming_up',
@@ -2078,12 +2095,16 @@ platform.post('/morning-briefing/run', async c => {
if ('error' in iidResult) return iidResult.error;
try {
- const response = await withResolvedDORetry(
- c.env,
- result.data.userId,
- iidResult.instanceId,
- stub => stub.runMorningBriefing(),
- 'runMorningBriefing'
+ const response = await withMorningBriefingWarmupRetry(
+ () =>
+ withResolvedDORetry(
+ c.env,
+ result.data.userId,
+ iidResult.instanceId,
+ stub => stub.runMorningBriefing(),
+ 'runMorningBriefing'
+ ),
+ { includeTimeout: false }
);
if (!response) {
return jsonError(
@@ -2094,6 +2115,16 @@ platform.post('/morning-briefing/run', async c => {
}
return c.json(response, 200);
} catch (err) {
+ if (isMorningBriefingWarmupError(err, { includeTimeout: false })) {
+ return jsonError('Gateway warming up, retrying shortly.', 503, 'gateway_warming_up');
+ }
+ if (isMorningBriefingTimeoutError(err)) {
+ return jsonError(
+ 'Morning Briefing run timed out while work may still be in progress. Check Last generated and Last delivery before retrying.',
+ 504,
+ 'morning_briefing_run_timeout'
+ );
+ }
const { message, status, code } = sanitizeOpenclawConfigError(err, 'morning-briefing/run');
return jsonError(message, status, code);
}