diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 888422e7..85219676 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "claudeclaw", "source": "./", "description": "Cron-like daemon that runs Claude prompts on a schedule", - "version": "1.0.44", + "version": "1.0.45", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 644babac..cc993e55 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.44", + "version": "1.0.45", "description": "Cron-like daemon that runs Claude prompts on a schedule" } diff --git a/commands/jobs.md b/commands/jobs.md index f731c486..817ae7b2 100644 --- a/commands/jobs.md +++ b/commands/jobs.md @@ -118,6 +118,8 @@ Legacy compatibility: `daily` is still accepted in existing job files. Logs are always written to `.claude/claudeclaw/logs/` regardless of the `notify` setting. +**`notifyChannel`**: When set to a Discord channel ID, routes the completion notification to that channel instead of DMing everyone in `discord.allowedUserIds`. This output is **not** gated by `discord.allowedUserIds` — anyone with access to the channel sees the job's output, so only point it at a channel where that's intended. + | Expression | Meaning | |------------------|--------------------------| | `* * * * *` | Every minute | diff --git a/src/__tests__/jobs.test.ts b/src/__tests__/jobs.test.ts index a7e0bb5c..85fed013 100644 --- a/src/__tests__/jobs.test.ts +++ b/src/__tests__/jobs.test.ts @@ -78,6 +78,26 @@ describe("loadJobs", () => { expect(job?.prompt).toBe("Summarise today's news"); }); + test("parses notifyChannel from frontmatter", async () => { + await writeFile( + join(LEGACY_JOBS_DIR, "blog-daily.md"), + jobMd("0 7 * * *", "Propose a topic", 'notifyChannel: "1498867541710213262"') + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "blog-daily"); + expect(job?.notifyChannel).toBe("1498867541710213262"); + }); + + test("notifyChannel is undefined when not set", async () => { + await writeFile( + join(LEGACY_JOBS_DIR, "no-channel.md"), + jobMd("0 3 * * *", "Run nightly report") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "no-channel"); + expect(job?.notifyChannel).toBeUndefined(); + }); + test("directory location overrides frontmatter agent field", async () => { // Even if the .md file says agent: wrong, the enclosing dir wins. await writeFile( diff --git a/src/commands/start.ts b/src/commands/start.ts index f062f074..5af7077b 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -422,21 +422,24 @@ export async function start(args: string[] = []) { // --- Discord --- let discordSendToUser: ((userId: string, text: string) => Promise) | null = null; + let discordSendToChannel: ((channelId: string, text: string) => Promise) | null = null; let discordToken = ""; async function initDiscord(token: string) { if (token && token !== discordToken) { - const { startGateway, sendMessageToUser, stopGateway } = await import("./discord"); + const { startGateway, sendMessageToUser, sendMessage, stopGateway } = await import("./discord"); if (discordToken) stopGateway(); startGateway(debugFlag); discordStopGateway = stopGateway; discordSendToUser = (userId, text) => sendMessageToUser(token, userId, text); + discordSendToChannel = (channelId, text) => sendMessage(token, channelId, text); discordToken = token; console.log(`[${ts()}] Discord: enabled`); } else if (!token && discordToken) { if (discordStopGateway) discordStopGateway(); discordStopGateway = null; discordSendToUser = null; + discordSendToChannel = null; discordToken = ""; console.log(`[${ts()}] Discord: disabled`); } @@ -636,11 +639,20 @@ export async function start(args: string[] = []) { } } - function forwardToDiscord(label: string, result: { exitCode: number; stdout: string; stderr: string }) { - if (!discordSendToUser || currentSettings.discord.allowedUserIds.length === 0) return; + function forwardToDiscord(label: string, result: { exitCode: number; stdout: string; stderr: string }, notifyChannel?: string) { const text = result.exitCode === 0 ? `${label ? `[${label}]\n` : ""}${result.stdout || "(empty)"}` : `${label ? `[${label}] ` : ""}error (exit ${result.exitCode}): ${extractErrorDetail(result) || "Unknown"}`; + + if (notifyChannel) { + if (!discordSendToChannel) return; + discordSendToChannel(notifyChannel, text).catch((err) => + console.error(`[Discord] Failed to forward to channel ${notifyChannel}: ${err}`) + ); + return; + } + + if (!discordSendToUser || currentSettings.discord.allowedUserIds.length === 0) return; for (const userId of currentSettings.discord.allowedUserIds) { discordSendToUser(userId, text).catch((err) => console.error(`[Discord] Failed to forward to ${userId}: ${err}`) @@ -904,7 +916,7 @@ export async function start(args: string[] = []) { if (job.notify === false) return; if (job.notify === "error" && r.exitCode === 0) return; forwardToTelegram(job.name, r); - forwardToDiscord(job.name, r); + forwardToDiscord(job.name, r, job.notifyChannel); }) .finally(async () => { if (job.recurring) return; diff --git a/src/jobs.ts b/src/jobs.ts index 3c797ca7..f6f8ca9b 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -9,6 +9,11 @@ export interface Job { prompt: string; recurring: boolean; notify: true | false | "error"; + /** + * When set, routes the completion notification to this Discord channel ID instead of DMing allowedUserIds. + * Not gated by discord.allowedUserIds — anyone with access to the channel sees the job output. + */ + notifyChannel?: string; /** When set, overrides the global model for this job. Useful for routing cheap tasks to haiku. */ model?: string; /** When set, overrides the global session timeout for this job (in seconds). */ @@ -65,6 +70,11 @@ function parseJobFile(name: string, content: string): Job | null { : notifyRaw === "error" ? "error" : true; + const notifyChannelLine = lines.find((l) => l.startsWith("notifyChannel:")); + const notifyChannel = notifyChannelLine + ? parseFrontmatterValue(notifyChannelLine.replace("notifyChannel:", "")) || undefined + : undefined; + const modelLine = lines.find((l) => l.startsWith("model:")); const model = modelLine ? parseFrontmatterValue(modelLine.replace("model:", "")) || undefined : undefined; @@ -96,7 +106,7 @@ function parseJobFile(name: string, content: string): Job | null { const retryDelayLine = lines.find((l) => l.startsWith("retry_delay:")); const retryDelay = retryDelayLine ? parseInt(parseFrontmatterValue(retryDelayLine.replace("retry_delay:", "")), 10) || undefined : undefined; - return { name, schedule, prompt, recurring, notify, model, timeoutSeconds, agent, label, enabled, retry, retryDelay }; + return { name, schedule, prompt, recurring, notify, notifyChannel, model, timeoutSeconds, agent, label, enabled, retry, retryDelay }; } export async function loadJobs(): Promise {