Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 2 additions & 0 deletions commands/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 16 additions & 4 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,21 +422,24 @@ export async function start(args: string[] = []) {

// --- Discord ---
let discordSendToUser: ((userId: string, text: string) => Promise<void>) | null = null;
let discordSendToChannel: ((channelId: string, text: string) => Promise<void>) | 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`);
}
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<Job[]> {
Expand Down
Loading