diff --git a/README.md b/README.md index 4d354ec..a4e8c78 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla ## Features -- **`/loop 5m `** — fixed interval (s/m/h/d supported) +- **`/loop 5m `** — fixed interval (s/m/h/d supported), runs immediately on creation, then repeats on schedule - **`/loop `** — runs immediately in Adaptive mode, then keeps the random fallback, reschedules from the result, or converts a clear recurring cadence to Fixed -- **`/loop`** — bare: read `.opencode/loop.md` or run built-in maintenance, immediately +- **`/loop`** — bare: read `.opencode/loop.md` (project) or `~/.opencode/loop.md` (user), or run built-in maintenance, immediately - **`/loop 30s --once `** — one-shot: fires once, then auto-cancels +- **`/proactive`** — full alias of `/loop` (Claude Code parity) - **`/loop help`** — full usage, flags, and examples in the terminal - **Claude Code-style flags** — `--cancel/--list/--status/--pause/--resume/--stop/--stop-all` map to the matching subcommand - **Per-session scoping** — tasks are bound to a `sessionID`; other sessions never see, fire, or manage them @@ -118,6 +119,11 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r /loop 30s --once remind me to stretch # one-shot: fires once, then auto-cancels ``` +A recurring fixed task **runs immediately on creation** (Claude Code behavior): +the creation turn is its first execution, then it repeats on schedule with the +next fire anchored to the creation time. A `--once` task instead becomes due +immediately and fires within one ticker period, then auto-cancels. + Fixed tasks use deterministic Jitter by default. Add `--jitter=false` for an exact interval or `--jitter=true` to enable it explicitly. Flags are only recognized **before the prompt begins**: anything after the first @@ -149,7 +155,7 @@ Adaptive-to-Fixed conversion defaults to `jitterEnabled: false`, so an explicit The fallback is written before the prompt is injected. A successful `reschedule` therefore replaces the fallback and is not overwritten after the model finishes. The preferred `delayMs` is relative to tool-call time, avoiding epoch arithmetic. An in-range model delay is stored exactly without Jitter; only an out-of-range request is clamped to the task's configured minimum or maximum delay. Fixed and Maintenance rescheduling remains unchanged. An absolute `nextDueAtMs` is also accepted, but passing it together with `delayMs` returns an error without changing the task. ### Bare `/loop` — custom default prompt -Create `.opencode/loop.md` (project) or `/.opencode/loop.md` (user) with your maintenance instructions: +Create `.opencode/loop.md` (project) or `~/.opencode/loop.md` (user-level, used when the project has none) with your maintenance instructions: ```markdown Check the release branch PR. If CI is red, pull the failing log, diagnose, and push a minimal fix. If new review comments have arrived, @@ -178,8 +184,9 @@ Trying to manage a task owned by another session reports "No task `` in this | Claude Code `/loop` | opencode-plugin-loop | |---|---| -| `/loop 5m ` | identical | +| `/loop 5m ` | identical — runs immediately on creation, then repeats | | `/loop ` (self-paced) | Adaptive: runs now, model picks the next check (fallback 1m–1h) | +| `/proactive` | alias: `/proactive` works exactly like `/loop` | | cancel/list via cron tools | `/loop cancel `, `/loop list` | | `--cancel`, `--list`, `--stop` | accepted — mapped to `cancel`, `list`, `stop` | | one-off reminder ("in 30m tell me X") | `/loop 30s --once ` | diff --git a/commands/proactive.md b/commands/proactive.md new file mode 100644 index 0000000..9938993 --- /dev/null +++ b/commands/proactive.md @@ -0,0 +1,7 @@ +--- +description: Alias of /loop — run prompts on a schedule. Natural-language Adaptive requests run immediately and the model decides the next check; explicit intervals support --jitter=true|false and --once. Tasks are scoped to the current session. See /loop help. +argument-hint: "[5m] [--jitter=true|false] [--once] [prompt text... | help | list | cancel | pause | resume | stop-all]" +agent: build +--- + +$ARGUMENTS diff --git a/package.json b/package.json index 17b6a48..0547804 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.7.4", + "version": "0.8.0", "description": "/loop command for opencode — run prompts on a schedule (fixed, adaptive, or maintenance), modeled after Claude Code's /loop", "type": "module", "main": "./dist/index.js", @@ -73,7 +73,8 @@ "opencode": { "plugin": true, "commands": [ - "commands/loop.md" + "commands/loop.md", + "commands/proactive.md" ] } } diff --git a/src/index.ts b/src/index.ts index a31e732..1ecd491 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,7 +217,7 @@ export const LoopPlugin: Plugin = async (ctx) => { // outer quotes before matching, mirroring handleUserCommand. for (const part of output?.parts ?? []) { if (part.type !== "text" || part.synthetic || part.ignored) continue - const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(stripOuterQuotes(part.text)) + const match = /^\/(?:loop|proactive)(?:\s+([\s\S]*))?$/.exec(stripOuterQuotes(part.text)) if (!match) continue await runLoopCommand(match[1] ?? "", input.sessionID, output.parts) return @@ -225,7 +225,8 @@ export const LoopPlugin: Plugin = async (ctx) => { }, "command.execute.before": async (input, output) => { - if (input.command !== "loop") return + // /proactive is a full alias of /loop (Claude Code parity). + if (input.command !== "loop" && input.command !== "proactive") return await runLoopCommand(input.arguments || "", input.sessionID, output.parts) }, } diff --git a/src/runtime-feedback.ts b/src/runtime-feedback.ts index ee6b9c8..9c6d984 100644 --- a/src/runtime-feedback.ts +++ b/src/runtime-feedback.ts @@ -55,6 +55,23 @@ export function buildFixedExecutionPrompt(task: { ].join("\n") } +/** + * First execution of a freshly created fixed task: the task runs immediately + * in the current turn (Claude Code behavior), then repeats on schedule. + */ +export function buildFixedFirstRunPrompt(input: { + prompt: string + schedule: string + taskId: string +}): string { + return [ + `The /loop task ${input.taskId} has just been scheduled to run ${input.schedule}. This is its first execution — it runs now, then repeats on schedule (cancel anytime with: /loop cancel ${input.taskId}).`, + "Briefly confirm the schedule in the user's language, then perform the task described below now and report the result concisely.", + "", + input.prompt, + ].join("\n") +} + export type LoopLogLevel = "debug" | "info" | "warn" | "error" export type LoopLogger = ( level: LoopLogLevel, diff --git a/src/scheduler.ts b/src/scheduler.ts index 6d9d937..d29860e 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -11,12 +11,13 @@ */ import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" import { join } from "node:path" import type { LoopTask } from "./types.js" import type { LoopStoreInstance as LoopStore } from "./store.js" import type { CronParserInstance as CronParser } from "./cron-parser.js" import type { JitterInstance as Jitter } from "./jitter.js" -import { buildFixedExecutionPrompt, buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js" +import { buildFixedExecutionPrompt, buildFixedFirstRunPrompt, buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js" import { buildAdaptiveExecutionPrompt, clampAdaptiveNextDueAt as clampAdaptivePolicyNextDueAt, @@ -136,9 +137,10 @@ export const LOOP_HELP = `/loop — run prompts on a schedule Usage: /loop Adaptive: runs now, the model picks the next check (fallback 1m–1h) - /loop Fixed interval: 30s, 5m, 2h, 1d (min 1s) - /loop Maintenance mode (uses .opencode/loop.md when present) + /loop Fixed interval: 30s, 5m, 2h, 1d (min 1s); runs immediately, then repeats + /loop Maintenance mode (uses .opencode/loop.md or ~/.opencode/loop.md when present) /loop help Show this help + /proactive ... Full alias of /loop Subcommands (tasks are bound to the session that created them): list | status Show this session's loop tasks @@ -264,15 +266,34 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta source: "user", sessionID, }) + if (task.once) { + // One-shot: make it due immediately — the ticker fires it within + // one tick and auto-cancels after the first successful fire. + await inst.opts.store.reschedule(task.id, Date.now()) + return { + task, + modelPrompt: buildLoopCreatedPrompt({ + prompt: flags.prompt, + schedule: `every ${interval.display}`, + taskId: task.id, + once: task.once, + }), + message: `🔁 Loop started: every ${interval.display}, prompt "${flags.prompt.slice(0, 50)}${flags.prompt.length > 50 ? "..." : ""}" [id=${task.id}] [s=${sessionID.slice(0, 8)}] (runs once). Cancel: \`/loop cancel ${task.id}\``, + } + } + // Recurring fixed tasks run immediately on creation (Claude Code + // behavior): execute in this turn, then repeat on schedule. The next + // fire is anchored to now, keeping wall-clock semantics. + await inst.rearmFixed(task) + await inst.opts.store.markFired(task.id, task.nextDueAt) return { task, - modelPrompt: buildLoopCreatedPrompt({ + modelPrompt: buildFixedFirstRunPrompt({ prompt: flags.prompt, schedule: `every ${interval.display}`, taskId: task.id, - once: task.once, }), - message: `🔁 Loop started: every ${interval.display}, prompt "${flags.prompt.slice(0, 50)}${flags.prompt.length > 50 ? "..." : ""}" [id=${task.id}] [s=${sessionID.slice(0, 8)}]${task.once ? " (runs once)" : ""}. Cancel: \`/loop cancel ${task.id}\``, + message: `🔁 Loop started: every ${interval.display} (running now), prompt "${flags.prompt.slice(0, 50)}${flags.prompt.length > 50 ? "..." : ""}" [id=${task.id}] [s=${sessionID.slice(0, 8)}]. Cancel: \`/loop cancel ${task.id}\``, } } @@ -386,9 +407,12 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta }, loadDefaultPrompt(directory) { + // Priority: project > user > built-in default (mirrors Claude Code's + // project `.claude/loop.md` vs user `~/.claude/loop.md`). const candidates = [ join(directory, ".opencode", "loop.md"), join(directory, "loop.md"), + join(homedir(), ".opencode", "loop.md"), ] for (const p of candidates) { if (existsSync(p)) { diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index b05e780..5b1a1e5 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -91,6 +91,10 @@ test("end-to-end: /loop 1m ping in sessionA → persists → fires only when act const data = JSON.parse(readFileSync(tasksFile, "utf-8")) assert.equal(data.tasks.length, 1) assert.equal(data.tasks[0].sessionID, "sA") + // Fixed tasks run immediately on creation, so lastFiredAt is already set; + // the assertion below checks the TICKER does not fire it again. + const firedAtCreation = data.tasks[0].lastFiredAt + assert.ok(firedAtCreation > 0, "first execution recorded at creation") data.tasks[0].nextDueAt = Date.now() - 1000 writeFileSync(tasksFile, JSON.stringify(data), "utf-8") @@ -104,7 +108,7 @@ test("end-to-end: /loop 1m ping in sessionA → persists → fires only when act // To verify isolation, we trust the unit tests in per-session.test.mjs. // Here we just verify the data state. const dataAfter = JSON.parse(readFileSync(tasksFile, "utf-8")) - assert.equal(dataAfter.tasks[0].lastFiredAt, 0, "task in A did not fire while active is B") + assert.equal(dataAfter.tasks[0].lastFiredAt, firedAtCreation, "task in A did not fire again while active is B") await hooks.dispose() } finally { @@ -412,10 +416,13 @@ test("starting a loop stays silent on the TUI", async () => { ) assert.equal(toastCalls.length, 0) - assert.match(output.parts[0].text, /Job ID: [a-z0-9]+/i) + // Fixed tasks run immediately on creation: the prompt is the first-run + // execution instruction (schedule confirmation + task body). + assert.match(output.parts[0].text, /task [a-z0-9]+ has just been scheduled/i) assert.match(output.parts[0].text, /every 5m/) - assert.match(output.parts[0].text, /same language/i) - assert.match(output.parts[0].text, /do not call tools/i) + assert.match(output.parts[0].text, /first execution/i) + assert.match(output.parts[0].text, /check the build/) + assert.match(output.parts[0].text, /user's language/i) } finally { if (hooks) await hooks.dispose() rmSync(dir, { recursive: true }) @@ -698,3 +705,35 @@ test("loop_schedule create: fixed interval validation matches the slash parser", rmSync(dir, { recursive: true }) } }) + +test("/proactive command is handled exactly like /loop", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-int-")) + let hooks + try { + const mockClient = { app: { async log() { return true } } } + hooks = await pluginModule.LoopPlugin({ + client: mockClient, + project: { id: "test" }, + directory: dir, + worktree: dir, + $: {}, + serverUrl: new URL("http://localhost:3000"), + experimental_workspace: { register: () => {} }, + }) + const output = { + parts: [{ id: "p1", sessionID: "sA", messageID: "m1", type: "text", text: "1m via alias" }], + } + await hooks["command.execute.before"]( + { command: "proactive", arguments: "1m via alias", sessionID: "sA" }, + output + ) + assert.equal(output.parts[0].synthetic, true) + assert.match(output.parts[0].text, /first execution/i) + const tasks = JSON.parse(readFileSync(join(dir, ".opencode/cache/loop/tasks.json"), "utf-8")) + assert.equal(tasks.tasks.length, 1) + assert.equal(tasks.tasks[0].prompt, "via alias") + } finally { + if (hooks) await hooks.dispose() + rmSync(dir, { recursive: true }) + } +}) diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index 5e97b13..04008bc 100644 --- a/tests/package-exports.test.mjs +++ b/tests/package-exports.test.mjs @@ -6,8 +6,8 @@ const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) -test("publishes the 0.7.4 release", () => { - assert.equal(packageJson.version, "0.7.4") +test("publishes the 0.8.0 release", () => { + assert.equal(packageJson.version, "0.8.0") }) test("publishes explicit server and TUI plugin entrypoints", () => { @@ -43,3 +43,10 @@ test("ships no dialog runtime dependencies", () => { test("test command always builds fresh output before running tests", () => { assert.match(packageJson.scripts.test, /^npm run build && /) }) + +test("bundles loop and proactive command definitions", () => { + assert.deepEqual(packageJson.opencode.commands, [ + "commands/loop.md", + "commands/proactive.md", + ]) +}) diff --git a/tests/per-session.test.mjs b/tests/per-session.test.mjs index 3d4e62d..a7288be 100644 --- a/tests/per-session.test.mjs +++ b/tests/per-session.test.mjs @@ -1132,7 +1132,8 @@ test("plugin: full session-scoped fire cycle via ticker", async () => { // Verify task data is still in expected state const dataAfter = JSON.parse(readFileSync(tasksFile, "utf-8")) assert.equal(dataAfter.tasks[0].sessionID, SID_A) - assert.equal(dataAfter.tasks[0].lastFiredAt, 0, "not yet fired") + assert.ok(dataAfter.tasks[0].lastFiredAt > 0, "fixed tasks run immediately on creation") + assert.equal(dataAfter.tasks[0].lastFiredAt, data.tasks[0].lastFiredAt, "ticker has not fired it again") await hooks.dispose() } finally { diff --git a/tests/run-mode.test.mjs b/tests/run-mode.test.mjs index ee7f10e..53ef81a 100644 --- a/tests/run-mode.test.mjs +++ b/tests/run-mode.test.mjs @@ -283,3 +283,17 @@ test("run mode: /loop in a later text part is still found (continue, not return) rmSync(dir, { recursive: true, force: true }) } }) + +test("run mode: '/proactive ...' is intercepted as a /loop alias", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-run-")) + try { + const hooks = await makeHooks(dir) + const out = textMessage("/proactive 1m ping via alias") + await hooks["chat.message"]({ sessionID: "sRun" }, out) + assert.equal(out.parts[0].synthetic, true) + assert.equal(taskCount(dir), 1, "alias creates the task") + await hooks.dispose() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/tests/scheduler.test.mjs b/tests/scheduler.test.mjs index 58b4c51..bcd3a8a 100644 --- a/tests/scheduler.test.mjs +++ b/tests/scheduler.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { mkdtempSync, rmSync } from "node:fs" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { LoopStore } from "../dist/store.js" @@ -755,3 +755,63 @@ test("/loop stop --all: the extra token is treated as an id and not found", asyn rmSync(dir, { recursive: true }) } }) + +// --- 0.8.0: fixed tasks run immediately on creation (Claude Code parity) --- + +test("recurring fixed task executes in the creation turn and is re-armed from now", async () => { + const { sched, store, dir } = makeScheduler(undefined, () => 0.5) + try { + const before = Date.now() + const r = await sched.handleUserCommand("5m check the build", "/tmp", "s1") + const after = Date.now() + assert.match(r.modelPrompt, /first execution/, "creation turn IS the first execution") + assert.match(r.modelPrompt, /check the build/) + assert.match(r.modelPrompt, /every 5m/) + const t = store.get(r.task.id) + assert.ok(t.lastFiredAt >= before && t.lastFiredAt <= after, "first run recorded") + // 5m is the medium jitter tier (±5%) — next fire is anchored to creation + // time within the jitter window. + assert.ok(t.nextDueAt >= before + 285_000, "next fire anchored to creation time") + assert.ok(t.nextDueAt <= after + 315_000, "within the jitter window") + } finally { + rmSync(dir, { recursive: true }) + } +}) + +test("one-shot fixed task is due immediately instead of running in the creation turn", async () => { + const { sched, store, dir } = makeScheduler() + try { + const r = await sched.handleUserCommand("30s --once remind me", "/tmp", "s1") + assert.equal(r.task.once, true) + assert.ok(r.task.nextDueAt <= Date.now(), "due now — ticker fires it within one tick") + assert.match(r.modelPrompt, /successfully created/, "creation confirmation, not execution") + assert.equal(store.get(r.task.id).lastFiredAt, 0, "no execution recorded yet") + } finally { + rmSync(dir, { recursive: true }) + } +}) + +// --- 0.8.0: user-level ~/.opencode/loop.md --- + +test("bare /loop falls back to the user-level loop.md, project file wins", async () => { + const { sched, dir } = makeScheduler() + const fakeHome = mkdtempSync(join(tmpdir(), "loop-home-")) + const project = mkdtempSync(join(tmpdir(), "loop-proj-")) + const realHome = process.env.HOME + try { + mkdirSync(join(fakeHome, ".opencode"), { recursive: true }) + writeFileSync(join(fakeHome, ".opencode", "loop.md"), "user-level maintenance") + process.env.HOME = fakeHome + + assert.equal(sched.loadDefaultPrompt(project), "user-level maintenance") + + mkdirSync(join(project, ".opencode"), { recursive: true }) + writeFileSync(join(project, ".opencode", "loop.md"), "project-level maintenance") + assert.equal(sched.loadDefaultPrompt(project), "project-level maintenance") + } finally { + process.env.HOME = realHome + rmSync(dir, { recursive: true }) + rmSync(fakeHome, { recursive: true, force: true }) + rmSync(project, { recursive: true, force: true }) + } +})