From 266c9e842f82493bd338e358807be8a6ec83458e Mon Sep 17 00:00:00 2001 From: Jack Bertram Date: Sun, 26 Apr 2026 09:42:47 +0100 Subject: [PATCH 01/65] feat(jobs): per-job timeout and session isolation Add a `timeout` frontmatter field (in seconds) to job files, allowing long-running jobs to override the global 5-minute session timeout. Also fix job session isolation: jobs now pass `job.name` as the threadId so each job runs in its own persistent session rather than sharing the main conversation session. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/start.ts | 2 +- src/jobs.ts | 8 +++++++- src/runner.ts | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/commands/start.ts b/src/commands/start.ts index 0879f2b..40e64f3 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -711,7 +711,7 @@ export async function start(args: string[] = []) { for (const job of currentJobs) { if (cronMatches(job.schedule, now, currentSettings.timezoneOffsetMinutes)) { resolvePrompt(job.prompt) - .then((prompt) => run(job.name, prompt, undefined, job.model)) + .then((prompt) => run(job.name, prompt, job.name, job.model, job.timeoutSeconds ? job.timeoutSeconds * 1000 : undefined)) .then((r) => { if (job.notify === false) return; if (job.notify === "error" && r.exitCode === 0) return; diff --git a/src/jobs.ts b/src/jobs.ts index 2ce946f..a0bd034 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -10,6 +10,8 @@ export interface Job { notify: true | false | "error"; /** 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). */ + timeoutSeconds?: number; } function parseFrontmatterValue(raw: string): string { @@ -55,7 +57,11 @@ function parseJobFile(name: string, content: string): Job | null { const modelLine = lines.find((l) => l.startsWith("model:")); const model = modelLine ? parseFrontmatterValue(modelLine.replace("model:", "")) || undefined : undefined; - return { name, schedule, prompt, recurring, notify, model }; + const timeoutLine = lines.find((l) => l.startsWith("timeout:")); + const timeoutRaw = timeoutLine ? parseFrontmatterValue(timeoutLine.replace("timeout:", "")) : ""; + const timeoutSeconds = timeoutRaw ? parseInt(timeoutRaw, 10) || undefined : undefined; + + return { name, schedule, prompt, recurring, notify, model, timeoutSeconds }; } export async function loadJobs(): Promise { diff --git a/src/runner.ts b/src/runner.ts index 46ecf35..b83b2cb 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -378,7 +378,7 @@ export async function compactCurrentSession(): Promise<{ success: boolean; messa : { success: false, message: `❌ Compact failed (${existing.sessionId.slice(0, 8)})` }; } -async function execClaude(name: string, prompt: string, threadId?: string, modelOverride?: string): Promise { +async function execClaude(name: string, prompt: string, threadId?: string, modelOverride?: string, timeoutMsOverride?: number): Promise { await mkdir(LOGS_DIR, { recursive: true }); const existing = threadId @@ -416,7 +416,7 @@ async function execClaude(name: string, prompt: string, threadId?: string, model api: fallback?.api ?? "", }; const securityArgs = buildSecurityArgs(security); - const timeoutMs = (settings as any).sessionTimeoutMs || CLAUDE_TIMEOUT_MS; + const timeoutMs = timeoutMsOverride ?? (settings as any).sessionTimeoutMs ?? CLAUDE_TIMEOUT_MS; console.log( `[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})` @@ -577,8 +577,8 @@ async function execClaude(name: string, prompt: string, threadId?: string, model return result; } -export async function run(name: string, prompt: string, threadId?: string, modelOverride?: string): Promise { - return enqueue(() => execClaude(name, prompt, threadId, modelOverride), threadId); +export async function run(name: string, prompt: string, threadId?: string, modelOverride?: string, timeoutMs?: number): Promise { + return enqueue(() => execClaude(name, prompt, threadId, modelOverride, timeoutMs), threadId); } async function streamClaude( From a0b5376bc9aeda25b9240c2a622ecebfec199be0 Mon Sep 17 00:00:00 2001 From: Jack Bertram Date: Sun, 26 Apr 2026 09:44:23 +0100 Subject: [PATCH 02/65] chore: bump plugin and marketplace version to 1.0.5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7fe7a26..1436bbe 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.4", + "version": "1.0.5", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d29e94d..7114d30 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.4", + "version": "1.0.5", "description": "Cron-like daemon that runs Claude prompts on a schedule" } From 39333b47d72deb36de5ff23a4809ea13ec10388d Mon Sep 17 00:00:00 2001 From: Jack Bertram Date: Sun, 26 Apr 2026 15:17:43 +0100 Subject: [PATCH 03/65] fix(sessionManager): skip non-snowflake thread IDs in createThreadSession When job names are used as threadIds for session isolation, they should not be persisted to sessions.json. Non-Discord IDs (e.g. "podcast-curator") cause 400 errors during rejoinThreads on every gateway reconnect. Co-Authored-By: Claude Sonnet 4.6 --- src/sessionManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 177634c..1742afa 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -55,8 +55,13 @@ export async function getThreadSession( }; } +function isSnowflake(id: string): boolean { + return /^\d{17,19}$/.test(id); +} + /** Create a new thread session after Claude outputs a session_id. */ export async function createThreadSession(threadId: string, sessionId: string): Promise { + if (!isSnowflake(threadId)) return; // skip job-named sessions (e.g. "podcast-curator") const data = await loadSessions(); data.threads[threadId] = { sessionId, From d91aeba2a4c9af4a0c80378d681708c212919873 Mon Sep 17 00:00:00 2001 From: Jack Bertram Date: Mon, 27 Apr 2026 22:17:36 +0100 Subject: [PATCH 04/65] fix(jobs): persist job sessions and validate timeout - Remove snowflake guard from createThreadSession so job-named sessions are persisted in sessions.json across daemon restarts - Add snowflake guard to rejoinThreads instead, so non-Discord IDs (job names) are skipped when rejoining Discord thread membership - Validate timeout frontmatter: reject zero, negative, and non-numeric values rather than passing them through to the runner --- src/commands/discord.ts | 2 ++ src/jobs.ts | 3 ++- src/sessionManager.ts | 5 ----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/commands/discord.ts b/src/commands/discord.ts index 47313da..d4db6fc 100644 --- a/src/commands/discord.ts +++ b/src/commands/discord.ts @@ -239,6 +239,8 @@ function extractReactionDirective(text: string): { cleanedText: string; reaction async function rejoinThreads(token: string): Promise { const threadSessions = await listThreadSessions(); for (const ts of threadSessions) { + // Skip non-snowflake keys (e.g. job names) — they are not Discord thread IDs + if (!/^\d{17,19}$/.test(ts.threadId)) continue; try { await discordApi(token, "DELETE", `/channels/${ts.threadId}/thread-members/@me`).catch(() => {}); await discordApi(token, "PUT", `/channels/${ts.threadId}/thread-members/@me`); diff --git a/src/jobs.ts b/src/jobs.ts index a0bd034..f4f0d57 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -59,7 +59,8 @@ function parseJobFile(name: string, content: string): Job | null { const timeoutLine = lines.find((l) => l.startsWith("timeout:")); const timeoutRaw = timeoutLine ? parseFrontmatterValue(timeoutLine.replace("timeout:", "")) : ""; - const timeoutSeconds = timeoutRaw ? parseInt(timeoutRaw, 10) || undefined : undefined; + const timeoutParsed = timeoutRaw ? parseInt(timeoutRaw, 10) : NaN; + const timeoutSeconds = Number.isFinite(timeoutParsed) && timeoutParsed > 0 ? timeoutParsed : undefined; return { name, schedule, prompt, recurring, notify, model, timeoutSeconds }; } diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 1742afa..177634c 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -55,13 +55,8 @@ export async function getThreadSession( }; } -function isSnowflake(id: string): boolean { - return /^\d{17,19}$/.test(id); -} - /** Create a new thread session after Claude outputs a session_id. */ export async function createThreadSession(threadId: string, sessionId: string): Promise { - if (!isSnowflake(threadId)) return; // skip job-named sessions (e.g. "podcast-curator") const data = await loadSessions(); data.threads[threadId] = { sessionId, From 7a213261337c96e87b284e36463cc51f5a4f63d6 Mon Sep 17 00:00:00 2001 From: TerrysPOV Date: Mon, 20 Apr 2026 00:32:58 +0100 Subject: [PATCH 05/65] fix: wire sessionTimeoutMs through parseSettings and fix error display in Discord - Add sessionTimeoutMs to Settings interface and DEFAULT_SETTINGS (30 min default) - Parse sessionTimeoutMs in parseSettings() so the setting is no longer silently dropped; runner.ts used (settings as any).sessionTimeoutMs which always fell through to the 5-minute CLAUDE_TIMEOUT_MS constant - Replace (settings as any).sessionTimeoutMs with settings.sessionTimeoutMs in runner.ts (two call sites) - Fix Discord error display to check stdout before stderr: Claude writes human- readable error messages to stdout on exit 1, not stderr, so the previous order always produced 'Unknown error' (cherry picked from commit 588e10ab5252861feefdbf4eb539e0f6dd1bbaf7) --- src/commands/discord.ts | 2 +- src/config.ts | 5 +++++ src/runner.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/discord.ts b/src/commands/discord.ts index d4db6fc..5c9db3c 100644 --- a/src/commands/discord.ts +++ b/src/commands/discord.ts @@ -670,7 +670,7 @@ async function handleMessageCreate(token: string, message: DiscordMessage): Prom const result = await runUserMessage("discord", prefixedPrompt, threadId); if (result.exitCode !== 0) { - await sendMessage(config.token, channelId, `Error (exit ${result.exitCode}): ${result.stderr || result.stdout || "Unknown error"}`); + await sendMessage(config.token, channelId, `Error (exit ${result.exitCode}): ${result.stdout || result.stderr || "Unknown error"}`); } else { const { cleanedText, reactionEmoji } = extractReactionDirective(result.stdout || ""); if (reactionEmoji) { diff --git a/src/config.ts b/src/config.ts index 35bfec4..9770131 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,7 @@ const DEFAULT_SETTINGS: Settings = { security: { level: "moderate", allowedTools: [], disallowedTools: [] }, web: { enabled: false, host: "127.0.0.1", port: 4632 }, stt: { baseUrl: "", model: "" }, + sessionTimeoutMs: 30 * 60 * 1000, }; export interface HeartbeatExcludeWindow { @@ -121,6 +122,7 @@ export interface Settings { security: SecurityConfig; web: WebConfig; stt: SttConfig; + sessionTimeoutMs: number; jobsDir?: string; } @@ -289,6 +291,9 @@ function parseSettings( baseUrl: typeof raw.stt?.baseUrl === "string" ? raw.stt.baseUrl.trim() : "", model: typeof raw.stt?.model === "string" ? raw.stt.model.trim() : "", }, + sessionTimeoutMs: typeof raw.sessionTimeoutMs === "number" && raw.sessionTimeoutMs > 0 + ? raw.sessionTimeoutMs + : 30 * 60 * 1000, ...(typeof raw.jobsDir === "string" && raw.jobsDir.trim() ? { jobsDir: raw.jobsDir.trim() } : {}), }; } diff --git a/src/runner.ts b/src/runner.ts index b83b2cb..2432485 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -362,7 +362,7 @@ export async function compactCurrentSession(): Promise<{ success: boolean; messa const settings = getSettings(); const securityArgs = buildSecurityArgs(settings.security); const baseEnv = cleanSpawnEnv(); - const timeoutMs = (settings as any).sessionTimeoutMs || CLAUDE_TIMEOUT_MS; + const timeoutMs = settings.sessionTimeoutMs; const ok = await runCompact( existing.sessionId, @@ -416,7 +416,7 @@ async function execClaude(name: string, prompt: string, threadId?: string, model api: fallback?.api ?? "", }; const securityArgs = buildSecurityArgs(security); - const timeoutMs = timeoutMsOverride ?? (settings as any).sessionTimeoutMs ?? CLAUDE_TIMEOUT_MS; + const timeoutMs = timeoutMsOverride ?? settings.sessionTimeoutMs; console.log( `[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})` From 88d877d55e7c0309315f7bc6c0f61bd27a33c34f Mon Sep 17 00:00:00 2001 From: TerrysPOV Date: Mon, 20 Apr 2026 15:38:33 +0100 Subject: [PATCH 06/65] refactor: consolidate session timeout into a single exported constant Extract DEFAULT_SESSION_TIMEOUT_MS (30 min) from config.ts and import it in runner.ts, removing the redundant local CLAUDE_TIMEOUT_MS constant. Previously the 30-minute value was duplicated in two places in config.ts and runner.ts had a separate 5-minute constant that served the same role as the default parameter for runClaudeOnce(). Now there is a single source of truth for the timeout default. (cherry picked from commit ef506988a5edaba13b50a2e799c9643d115fa0c7) --- src/config.ts | 7 +++++-- src/runner.ts | 7 ++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9770131..30c8ca0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,9 @@ const SETTINGS_FILE = join(HEARTBEAT_DIR, "settings.json"); const DEFAULT_JOBS_DIR = join(HEARTBEAT_DIR, "jobs"); const LOGS_DIR = join(HEARTBEAT_DIR, "logs"); +/** Default Claude session timeout (30 minutes). Exported so runner.ts can reference the same value. */ +export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; + export function getJobsDir(): string { if (cached?.jobsDir) { return isAbsolute(cached.jobsDir) ? cached.jobsDir : join(process.cwd(), cached.jobsDir); @@ -68,7 +71,7 @@ const DEFAULT_SETTINGS: Settings = { security: { level: "moderate", allowedTools: [], disallowedTools: [] }, web: { enabled: false, host: "127.0.0.1", port: 4632 }, stt: { baseUrl: "", model: "" }, - sessionTimeoutMs: 30 * 60 * 1000, + sessionTimeoutMs: DEFAULT_SESSION_TIMEOUT_MS, }; export interface HeartbeatExcludeWindow { @@ -293,7 +296,7 @@ function parseSettings( }, sessionTimeoutMs: typeof raw.sessionTimeoutMs === "number" && raw.sessionTimeoutMs > 0 ? raw.sessionTimeoutMs - : 30 * 60 * 1000, + : DEFAULT_SESSION_TIMEOUT_MS, ...(typeof raw.jobsDir === "string" && raw.jobsDir.trim() ? { jobsDir: raw.jobsDir.trim() } : {}), }; } diff --git a/src/runner.ts b/src/runner.ts index 2432485..032da2e 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -8,7 +8,7 @@ import { incrementThreadTurn, markThreadCompactWarned, } from "./sessionManager"; -import { getSettings, type ModelConfig, type SecurityConfig } from "./config"; +import { getSettings, DEFAULT_SESSION_TIMEOUT_MS, type ModelConfig, type SecurityConfig } from "./config"; import { buildClockPromptPrefix } from "./timezone"; import { selectModel } from "./model-router"; @@ -152,15 +152,12 @@ function buildChildEnv(baseEnv: Record, model: string, api: stri return childEnv; } -/** Default timeout for a single Claude Code invocation (5 minutes). */ -const CLAUDE_TIMEOUT_MS = 5 * 60 * 1000; - async function runClaudeOnce( baseArgs: string[], model: string, api: string, baseEnv: Record, - timeoutMs: number = CLAUDE_TIMEOUT_MS + timeoutMs: number = DEFAULT_SESSION_TIMEOUT_MS ): Promise<{ rawStdout: string; stderr: string; exitCode: number }> { const args = [...baseArgs]; const normalizedModel = model.trim().toLowerCase(); From 41b6b5296f621b92ba1dbc382e26c4f563e51f74 Mon Sep 17 00:00:00 2001 From: TerrysPOV Date: Thu, 23 Apr 2026 23:26:55 +0100 Subject: [PATCH 07/65] feat: add agent-scoped job support at agents//jobs/.md Adds a second job location scanned by loadJobs(): agents//jobs/*.md at the project root. This path is outside .claude/ and is therefore not subject to Claude Code's hardcoded write protection on .claude/ directories, which blocks writes even with --dangerously-skip-permissions. - jobs.ts: scan agents/*/jobs/*.md after legacy .claude/claudeclaw/jobs/; add agent, label, enabled fields to Job interface; directory location is authoritative (overrides any frontmatter agent: value) - sessions.ts: add optional agentName param to all session functions; agent sessions stored at agents//session.json (outside .claude/) - runner.ts: thread agentName through execClaude and run(); session calls branch on agentName the same way existing code branches on threadId - commands/start.ts: pass job.agent to run() at the cron fire site Backwards compatible: legacy .claude/claudeclaw/jobs/ still scanned and works unchanged. No new dependencies. No new processes. (cherry picked from commit 53e3af311ffbdd9b177d21254aa18745e634ee30) --- src/__tests__/jobs.test.ts | 183 +++++++++++++++++++++++++++++++++++++ src/commands/start.ts | 11 ++- src/jobs.ts | 81 ++++++++++++++-- src/runner.ts | 36 ++++++-- src/sessions.ts | 57 ++++++++---- 5 files changed, 329 insertions(+), 39 deletions(-) create mode 100644 src/__tests__/jobs.test.ts diff --git a/src/__tests__/jobs.test.ts b/src/__tests__/jobs.test.ts new file mode 100644 index 0000000..2139178 --- /dev/null +++ b/src/__tests__/jobs.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdir, writeFile, rm } from "fs/promises"; +import { join } from "path"; + +const TEST_ROOT = join(import.meta.dir, "../../test-sandbox-jobs"); +const LEGACY_JOBS_DIR = join(TEST_ROOT, ".claude", "claudeclaw", "jobs"); +const AGENTS_DIR = join(TEST_ROOT, "agents"); + +async function resetSandbox() { + await rm(TEST_ROOT, { recursive: true, force: true }); + await mkdir(LEGACY_JOBS_DIR, { recursive: true }); + await mkdir(join(AGENTS_DIR, "suzy", "jobs"), { recursive: true }); + await mkdir(join(AGENTS_DIR, "reg", "jobs"), { recursive: true }); +} + +afterAll(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); +}); + +function jobMd(schedule: string, prompt: string, extra = ""): string { + const extras = extra ? extra + "\n" : ""; + return `---\nschedule: ${schedule}\nrecurring: true\n${extras}---\n${prompt}\n`; +} + +/** Run loadJobs() in the sandbox dir via a child bun process (so process.cwd() == TEST_ROOT). */ +async function loadJobsInSandbox(): Promise { + const script = ` +import { loadJobs } from ${JSON.stringify(join(import.meta.dir, "..", "jobs"))}; +const jobs = await loadJobs(); +process.stdout.write(JSON.stringify(jobs)); +`; + const scriptPath = join(TEST_ROOT, "_run.ts"); + await writeFile(scriptPath, script); + const proc = Bun.spawn(["bun", "run", scriptPath], { + cwd: TEST_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return JSON.parse(out || "[]"); +} + +// ─── Integration tests ──────────────────────────────────────────────────── + +describe("loadJobs", () => { + beforeEach(resetSandbox); + + test("empty dirs → zero jobs, no throw", async () => { + const jobs = await loadJobsInSandbox(); + expect(jobs).toEqual([]); + }); + + test("loads job from legacy .claude/claudeclaw/jobs/", async () => { + await writeFile( + join(LEGACY_JOBS_DIR, "nightly.md"), + jobMd("0 3 * * *", "Run nightly report") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "nightly"); + expect(job).toBeDefined(); + expect(job?.agent).toBeUndefined(); // not agent-scoped + expect(job?.schedule).toBe("0 3 * * *"); + expect(job?.prompt).toBe("Run nightly report"); + }); + + test("loads job from agents//jobs/ (Phase 17 path)", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "daily-digest.md"), + jobMd("0 9 * * *", "Summarise today's news") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "suzy/daily-digest"); + expect(job).toBeDefined(); + expect(job?.agent).toBe("suzy"); + expect(job?.label).toBe("daily-digest"); + expect(job?.schedule).toBe("0 9 * * *"); + expect(job?.prompt).toBe("Summarise today's news"); + }); + + test("directory location overrides frontmatter agent field", async () => { + // Even if the .md file says agent: wrong, the enclosing dir wins. + await writeFile( + join(AGENTS_DIR, "reg", "jobs", "seo.md"), + jobMd("30 10 * * *", "SEO review", "agent: wrong-agent") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "reg/seo"); + expect(job?.agent).toBe("reg"); + }); + + test("enabled: false excludes job", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "disabled.md"), + jobMd("0 12 * * *", "Disabled", "enabled: false") + ); + const jobs = await loadJobsInSandbox(); + expect(jobs.find((j) => j.name === "suzy/disabled")).toBeUndefined(); + }); + + test("returns jobs from both legacy and agent-scoped locations together", async () => { + await writeFile(join(LEGACY_JOBS_DIR, "nightly.md"), jobMd("0 3 * * *", "Nightly")); + await writeFile(join(AGENTS_DIR, "suzy", "jobs", "morning.md"), jobMd("0 9 * * *", "Morning")); + const jobs = await loadJobsInSandbox(); + const names = jobs.map((j) => j.name); + expect(names).toContain("nightly"); + expect(names).toContain("suzy/morning"); + }); + + test("missing agents/ dir is silently ignored (no throw)", async () => { + await rm(AGENTS_DIR, { recursive: true, force: true }); + const jobs = await loadJobsInSandbox(); + expect(Array.isArray(jobs)).toBe(true); + }); + + test("agent dir without jobs/ subdir is skipped", async () => { + // publisher/ exists but has no jobs/ subdirectory + await mkdir(join(AGENTS_DIR, "publisher"), { recursive: true }); + const jobs = await loadJobsInSandbox(); + expect(jobs.filter((j) => j.name.startsWith("publisher/"))).toEqual([]); + }); + + test("job file without schedule: field is skipped gracefully", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "bad.md"), + "---\nprompt: test\n---\nNo schedule line.\n" + ); + // Should not throw, should return other valid jobs + const jobs = await loadJobsInSandbox(); + expect(jobs.find((j) => j.name === "suzy/bad")).toBeUndefined(); + }); +}); + +// ─── Unit: Job type and session path assertions ─────────────────────────── + +describe("Job type", () => { + test("includes agent, label, enabled fields", () => { + const job: import("../jobs").Job = { + name: "agent/job", + schedule: "0 9 * * *", + prompt: "test", + recurring: true, + notify: true, + agent: "myagent", + label: "myjob", + enabled: true, + }; + expect(job.agent).toBe("myagent"); + expect(job.label).toBe("myjob"); + expect(job.enabled).toBe(true); + }); +}); + +describe("sessions — agent-scoped paths", () => { + test("getSession/createSession/incrementTurn accept optional agentName", async () => { + const src = await Bun.file(join(import.meta.dir, "../sessions.ts")).text(); + // All public functions should have agentName? param + expect(src).toContain("getSession(\n agentName?: string"); + expect(src).toContain("createSession(sessionId: string, agentName?: string)"); + expect(src).toContain("incrementTurn(agentName?: string)"); + expect(src).toContain("markCompactWarned(agentName?: string)"); + }); + + test("agent sessions stored outside .claude/", async () => { + const src = await Bun.file(join(import.meta.dir, "../sessions.ts")).text(); + // Verify path uses AGENTS_DIR (project root) not HEARTBEAT_DIR (.claude/...) + expect(src).toContain('join(AGENTS_DIR, agentName, "session.json")'); + }); +}); + +// ─── Unit: protection-bug validation (the core motivation) ─────────────── + +describe("write-protection bug validation", () => { + test("agent-scoped job path is outside .claude/ (key property)", () => { + // The Claude Code CLI hardcodes a protection list for .claude/ paths. + // Agent-scoped jobs live at agents//jobs/.md — no .claude/ prefix. + // This test documents the requirement explicitly. + const legacyPath = join(process.cwd(), ".claude", "claudeclaw", "jobs", "job.md"); + const agentPath = join(process.cwd(), "agents", "suzy", "jobs", "daily.md"); + expect(legacyPath).toContain("/.claude/"); + expect(agentPath).not.toContain("/.claude/"); + }); +}); diff --git a/src/commands/start.ts b/src/commands/start.ts index 40e64f3..a677186 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -711,7 +711,16 @@ export async function start(args: string[] = []) { for (const job of currentJobs) { if (cronMatches(job.schedule, now, currentSettings.timezoneOffsetMinutes)) { resolvePrompt(job.prompt) - .then((prompt) => run(job.name, prompt, job.name, job.model, job.timeoutSeconds ? job.timeoutSeconds * 1000 : undefined)) + .then((prompt) => + run( + job.name, + prompt, + job.agent ? undefined : job.name, + job.model, + job.timeoutSeconds ? job.timeoutSeconds * 1000 : undefined, + job.agent + ) + ) .then((r) => { if (job.notify === false) return; if (job.notify === "error" && r.exitCode === 0) return; diff --git a/src/jobs.ts b/src/jobs.ts index f4f0d57..2748b08 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -2,7 +2,10 @@ import { readdir } from "fs/promises"; import { join } from "path"; import { getJobsDir } from "./config"; +const AGENTS_DIR = join(process.cwd(), "agents"); + export interface Job { + /** Scheduler key. For standalone jobs this is the file stem. For agent-scoped jobs this is "agent/label". */ name: string; schedule: string; prompt: string; @@ -12,6 +15,12 @@ export interface Job { model?: string; /** When set, overrides the global session timeout for this job (in seconds). */ timeoutSeconds?: number; + /** If set, this job is scoped to an agent. */ + agent?: string; + /** Human-readable label for agent-scoped jobs (file stem). */ + label?: string; + /** When false, the job is loaded but not scheduled. Defaults to true. */ + enabled?: boolean; } function parseFrontmatterValue(raw: string): string { @@ -62,29 +71,85 @@ function parseJobFile(name: string, content: string): Job | null { const timeoutParsed = timeoutRaw ? parseInt(timeoutRaw, 10) : NaN; const timeoutSeconds = Number.isFinite(timeoutParsed) && timeoutParsed > 0 ? timeoutParsed : undefined; - return { name, schedule, prompt, recurring, notify, model, timeoutSeconds }; + const agentLine = lines.find((l) => l.startsWith("agent:")); + const agentRaw = agentLine ? parseFrontmatterValue(agentLine.replace("agent:", "")) : ""; + const agent = agentRaw || undefined; + + const labelLine = lines.find((l) => l.startsWith("label:")); + const labelRaw = labelLine ? parseFrontmatterValue(labelLine.replace("label:", "")) : ""; + const label = labelRaw || undefined; + + const enabledLine = lines.find((l) => l.startsWith("enabled:")); + const enabledRaw = enabledLine + ? parseFrontmatterValue(enabledLine.replace("enabled:", "")).toLowerCase() + : ""; + const enabled = + enabledRaw === "false" || enabledRaw === "no" || enabledRaw === "0" + ? false + : undefined; + + return { name, schedule, prompt, recurring, notify, model, timeoutSeconds, agent, label, enabled }; } export async function loadJobs(): Promise { const jobs: Job[] = []; - let files: string[]; + + let flatFiles: string[] = []; try { - files = await readdir(getJobsDir()); + flatFiles = await readdir(getJobsDir()); } catch { - return jobs; + /* missing dir is fine */ } - - for (const file of files) { + for (const file of flatFiles) { if (!file.endsWith(".md")) continue; const content = await Bun.file(join(getJobsDir(), file)).text(); const job = parseJobFile(file.replace(/\.md$/, ""), content); - if (job) jobs.push(job); + if (!job) continue; + if (job.enabled !== false) jobs.push(job); + } + + // agents/ lives at project root (outside .claude/), so agent-managed jobs are writable by Claude Code. + let agentDirs: string[] = []; + try { + agentDirs = await readdir(AGENTS_DIR); + } catch { + return jobs; + } + for (const agentName of agentDirs) { + const agentJobsDir = join(AGENTS_DIR, agentName, "jobs"); + let jobFiles: string[] = []; + try { + jobFiles = await readdir(agentJobsDir); + } catch { + continue; + } + for (const file of jobFiles) { + if (!file.endsWith(".md")) continue; + const labelFromFile = file.replace(/\.md$/, ""); + const content = await Bun.file(join(agentJobsDir, file)).text(); + const job = parseJobFile(`${agentName}/${labelFromFile}`, content); + if (!job) continue; + job.agent = agentName; + job.label = labelFromFile; + if (job.enabled !== false) jobs.push(job); + } } + return jobs; } +function resolveJobPath(jobName: string): string { + const slash = jobName.indexOf("/"); + if (slash > 0 && slash < jobName.length - 1) { + const agentName = jobName.slice(0, slash); + const label = jobName.slice(slash + 1); + return join(AGENTS_DIR, agentName, "jobs", `${label}.md`); + } + return join(getJobsDir(), `${jobName}.md`); +} + export async function clearJobSchedule(jobName: string): Promise { - const path = join(getJobsDir(), `${jobName}.md`); + const path = resolveJobPath(jobName); const content = await Bun.file(path).text(); const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); if (!match) return; diff --git a/src/runner.ts b/src/runner.ts index 032da2e..087a2e4 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -375,12 +375,19 @@ export async function compactCurrentSession(): Promise<{ success: boolean; messa : { success: false, message: `❌ Compact failed (${existing.sessionId.slice(0, 8)})` }; } -async function execClaude(name: string, prompt: string, threadId?: string, modelOverride?: string, timeoutMsOverride?: number): Promise { +async function execClaude( + name: string, + prompt: string, + threadId?: string, + modelOverride?: string, + timeoutMsOverride?: number, + agentName?: string +): Promise { await mkdir(LOGS_DIR, { recursive: true }); const existing = threadId ? await getThreadSession(threadId) - : await getSession(); + : await getSession(agentName); const isNew = !existing; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`); @@ -488,8 +495,9 @@ async function execClaude(name: string, prompt: string, threadId?: string, model await createThreadSession(threadId, sessionId); console.log(`[${new Date().toLocaleTimeString()}] Thread session created: ${sessionId} (thread ${threadId.slice(0, 8)})`); } else { - await createSession(sessionId); - console.log(`[${new Date().toLocaleTimeString()}] Session created: ${sessionId}`); + await createSession(sessionId, agentName); + const label = agentName ? ` (agent ${agentName})` : ""; + console.log(`[${new Date().toLocaleTimeString()}] Session created: ${sessionId}${label}`); } } catch (e) { console.error(`[${new Date().toLocaleTimeString()}] Failed to parse session from Claude output:`, e); @@ -549,7 +557,7 @@ async function execClaude(name: string, prompt: string, threadId?: string, model }); if (retryExec.exitCode === 0) { - const count = threadId ? await incrementThreadTurn(threadId) : await incrementTurn(); + const count = threadId ? await incrementThreadTurn(threadId) : await incrementTurn(agentName); console.log(`[${new Date().toLocaleTimeString()}] Turn count: ${count} (after compact + retry)`); } return retryResult; @@ -558,14 +566,15 @@ async function execClaude(name: string, prompt: string, threadId?: string, model // --- Turn tracking & compact warning --- if (exitCode === 0 && !isNew) { - const turnCount = threadId ? await incrementThreadTurn(threadId) : await incrementTurn(); - console.log(`[${new Date().toLocaleTimeString()}] Turn count: ${turnCount}${threadId ? ` (thread ${threadId.slice(0, 8)})` : ""}`); + const turnCount = threadId ? await incrementThreadTurn(threadId) : await incrementTurn(agentName); + const turnLabel = threadId ? ` (thread ${threadId.slice(0, 8)})` : agentName ? ` (agent ${agentName})` : ""; + console.log(`[${new Date().toLocaleTimeString()}] Turn count: ${turnCount}${turnLabel}`); if (turnCount >= COMPACT_WARN_THRESHOLD && existing && !existing.compactWarned) { if (threadId) { await markThreadCompactWarned(threadId); } else { - await markCompactWarned(); + await markCompactWarned(agentName); } emitCompactEvent({ type: "warn", turnCount }); } @@ -574,8 +583,15 @@ async function execClaude(name: string, prompt: string, threadId?: string, model return result; } -export async function run(name: string, prompt: string, threadId?: string, modelOverride?: string, timeoutMs?: number): Promise { - return enqueue(() => execClaude(name, prompt, threadId, modelOverride, timeoutMs), threadId); +export async function run( + name: string, + prompt: string, + threadId?: string, + modelOverride?: string, + timeoutMs?: number, + agentName?: string +): Promise { + return enqueue(() => execClaude(name, prompt, threadId, modelOverride, timeoutMs, agentName), threadId); } async function streamClaude( diff --git a/src/sessions.ts b/src/sessions.ts index 9c2e3f4..8ee8031 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -3,6 +3,7 @@ import { unlink, readdir, rename } from "fs/promises"; const HEARTBEAT_DIR = join(process.cwd(), ".claude", "claudeclaw"); const SESSION_FILE = join(HEARTBEAT_DIR, "session.json"); +const AGENTS_DIR = join(process.cwd(), "agents"); export interface GlobalSession { sessionId: string; @@ -12,9 +13,23 @@ export interface GlobalSession { compactWarned: boolean; } +// Module-level cache is for the GLOBAL session only. +// Agent sessions bypass this cache — they read/write directly. let current: GlobalSession | null = null; -async function loadSession(): Promise { +function sessionPathFor(agentName?: string): string { + if (agentName) return join(AGENTS_DIR, agentName, "session.json"); + return SESSION_FILE; +} + +async function loadSession(agentName?: string): Promise { + if (agentName) { + try { + return await Bun.file(sessionPathFor(agentName)).json(); + } catch { + return null; + } + } if (current) return current; try { current = await Bun.file(SESSION_FILE).json(); @@ -24,63 +39,65 @@ async function loadSession(): Promise { } } -async function saveSession(session: GlobalSession): Promise { - current = session; - await Bun.write(SESSION_FILE, JSON.stringify(session, null, 2) + "\n"); +async function saveSession(session: GlobalSession, agentName?: string): Promise { + if (!agentName) current = session; + await Bun.write(sessionPathFor(agentName), JSON.stringify(session, null, 2) + "\n"); } /** Returns the existing session or null. Never creates one. */ -export async function getSession(): Promise<{ sessionId: string; turnCount: number; compactWarned: boolean } | null> { - const existing = await loadSession(); +export async function getSession( + agentName?: string +): Promise<{ sessionId: string; turnCount: number; compactWarned: boolean } | null> { + const existing = await loadSession(agentName); if (existing) { // Backfill missing fields from older session.json files if (typeof existing.turnCount !== "number") existing.turnCount = 0; if (typeof existing.compactWarned !== "boolean") existing.compactWarned = false; existing.lastUsedAt = new Date().toISOString(); - await saveSession(existing); + await saveSession(existing, agentName); return { sessionId: existing.sessionId, turnCount: existing.turnCount, compactWarned: existing.compactWarned }; } return null; } /** Save a session ID obtained from Claude Code's output. */ -export async function createSession(sessionId: string): Promise { +export async function createSession(sessionId: string, agentName?: string): Promise { await saveSession({ sessionId, createdAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), turnCount: 0, compactWarned: false, - }); + }, agentName); } /** Returns session metadata without mutating lastUsedAt. */ -export async function peekSession(): Promise { - return await loadSession(); +export async function peekSession(agentName?: string): Promise { + return await loadSession(agentName); } /** Increment the turn counter after a successful Claude invocation. */ -export async function incrementTurn(): Promise { - const existing = await loadSession(); +export async function incrementTurn(agentName?: string): Promise { + const existing = await loadSession(agentName); if (!existing) return 0; if (typeof existing.turnCount !== "number") existing.turnCount = 0; existing.turnCount += 1; - await saveSession(existing); + await saveSession(existing, agentName); return existing.turnCount; } /** Mark that the compact warning has been sent for the current session. */ -export async function markCompactWarned(): Promise { - const existing = await loadSession(); +export async function markCompactWarned(agentName?: string): Promise { + const existing = await loadSession(agentName); if (!existing) return; existing.compactWarned = true; - await saveSession(existing); + await saveSession(existing, agentName); } -export async function resetSession(): Promise { - current = null; +export async function resetSession(agentName?: string): Promise { + if (!agentName) current = null; try { - await unlink(SESSION_FILE); + await unlink(sessionPathFor(agentName)); } catch { // already gone } From 820331dd2d7f198acbe1357df97c4cb38f3922b5 Mon Sep 17 00:00:00 2001 From: TerrysPOV Date: Sat, 25 Apr 2026 12:42:34 +0100 Subject: [PATCH 08/65] fix: route clearJobSchedule and status to agent-scoped paths clearJobSchedule() now resolves agent job names (agentName/label format) to agents//jobs/