Skip to content
Merged
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,15 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r
/loop 2h look for failing CI runs
/loop 2m --jitter=false check the latest package version
/loop 30s --once remind me to stretch # one-shot: fires once, then auto-cancels
/loop check the deploy every 20m # trailing "every" clause ≡ /loop 20m check the deploy
/loop check CI every 5 minutes # word units work too: seconds/minutes/hours/days
```

A trailing `every <interval>` clause is extracted deterministically (Claude
Code rule 2): the interval applies and the rest is the prompt. `check every PR`
— no time expression after "every" — is not treated as a schedule and stays
Adaptive.

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
Expand Down Expand Up @@ -162,6 +169,11 @@ diagnose, and push a minimal fix. If new review comments have arrived,
address each one. If everything is green, say so in one line.
```

The file is **re-read on every run** (Claude Code behavior): editing loop.md
takes effect on the next fire with the full new content; when the content is
unchanged, only a short reminder is injected (prompt-cache friendly); if the
file is deleted, that run is skipped and the task stays armed.

### Subcommands

All subcommands are **scoped to the current session** — tasks created in other sessions are invisible to them, exactly like Claude Code's per-session `/loop` jobs.
Expand All @@ -185,6 +197,7 @@ Trying to manage a task owned by another session reports "No task `<id>` in this
| Claude Code `/loop` | opencode-plugin-loop |
|---|---|
| `/loop 5m <prompt>` | identical — runs immediately on creation, then repeats |
| trailing "every" clause (`... every 20m`) | identical — deterministically extracted as a fixed interval |
| `/loop <prompt>` (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 <id>`, `/loop list` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-plugin-loop",
"version": "0.8.0",
"version": "0.8.1",
"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",
Expand Down
24 changes: 23 additions & 1 deletion src/cron-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const UNIT_TO_MS: Record<string, number> = {
}
const MIN_INTERVAL_MS = 1_000

/** Word units accepted in trailing "every" clauses, mapped to s/m/h/d. */
const EVERY_UNIT: Record<string, string> = {
s: "s", sec: "s", secs: "s", second: "s", seconds: "s",
m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
d: "d", day: "d", days: "d",
}

/**
* Single validator shared by the slash parser, the LLM tool `create`, and
* `set_fixed` — all entries must accept/reject the same fixed intervals.
Expand Down Expand Up @@ -80,7 +88,10 @@ export function CronParser(this: unknown): CronParserInstance {

/** Try to extract an interval from a user command like "5m check deploy".
* `rest` is the ORIGINAL substring after the interval token (not a token
* re-join), so prompt whitespace and newlines are preserved verbatim. */
* re-join), so prompt whitespace and newlines are preserved verbatim.
* When the first token is not an interval, a trailing "every" clause is
* extracted instead (Claude Code rule 2): "check deploy every 20m" →
* fixed 20m + "check deploy". "check every PR" does not match. */
extractInterval(text: string) {
const trimmed = text.trim()
if (!trimmed) return { interval: null, rest: text }
Expand All @@ -93,6 +104,17 @@ export function CronParser(this: unknown): CronParserInstance {
rest: trimmed.slice(first.length).replace(/^\s+/, ""),
}
}
const every = /\bevery\s+(\d+(?:\.\d+)?)\s*([smhd]|seconds?|minutes?|hours?|days?)\s*$/i.exec(trimmed)
if (every) {
const unit = EVERY_UNIT[every[2].toLowerCase()]
const parsedTail = unit ? inst.parse(`${every[1]}${unit}`) : null
if (parsedTail) {
return {
interval: parsedTail,
rest: trimmed.slice(0, every.index).trim(),
}
}
}
return { interval: null, rest: text }
},

Expand Down
29 changes: 29 additions & 0 deletions src/runtime-feedback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Part } from "@opencode-ai/sdk"
import { createHash } from "node:crypto"

const SERVICE = "opencode-plugin-loop"
const HANDLED_COMMAND_PROMPT =
Expand Down Expand Up @@ -72,6 +73,34 @@ export function buildFixedFirstRunPrompt(input: {
].join("\n")
}

/** Stable short content hash for loop.md change detection. */
export function hashContent(content: string): string {
return createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16)
}

/**
* Maintenance execution for file-backed tasks (loop.md is re-read on every
* fire, Claude Code style): fresh/changed content is injected in full;
* unchanged content gets a short cache-friendly reminder.
*/
export function buildMaintenanceExecutionPrompt(
task: { id: string },
freshContent: string | null
): string {
if (freshContent === null) {
return [
`This is the scheduled execution of /loop maintenance task ${task.id}. The maintenance instructions from loop.md are unchanged since the previous run earlier in this conversation — refer to them above.`,
"If there is pending maintenance work, perform it now and report concisely. If nothing is pending, reply with one line saying so and call loop_schedule(action=\"cancel\", taskId=\"" + task.id + "\") to end the loop.",
].join("\n")
}
return [
`This is the scheduled execution of /loop maintenance task ${task.id}. The maintenance instructions from loop.md (re-read at fire time; they may have been edited since the last run) follow below — perform them now, then report concisely.`,
`When the work is complete and no further checks are needed, call loop_schedule(action="cancel", taskId="${task.id}") to end the loop.`,
"",
freshContent,
].join("\n")
}

export type LoopLogLevel = "debug" | "info" | "warn" | "error"
export type LoopLogger = (
level: LoopLogLevel,
Expand Down
74 changes: 59 additions & 15 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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, buildFixedFirstRunPrompt, buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js"
import { buildFixedExecutionPrompt, buildFixedFirstRunPrompt, buildLoopCreatedPrompt, buildMaintenanceExecutionPrompt, errorMessage, hashContent, type LoopLogger } from "./runtime-feedback.js"
import {
buildAdaptiveExecutionPrompt,
clampAdaptiveNextDueAt as clampAdaptivePolicyNextDueAt,
Expand Down Expand Up @@ -53,6 +53,7 @@ interface SchedulerInstance {
handleResume(id: string): Promise<CommandParseResult>
formatTaskList(tasks: LoopTask[]): string
loadDefaultPrompt(directory: string): string
loadDefaultPromptSource(directory: string): { path: string | null; content: string }
getDueTasks(now?: number): Promise<LoopTask[]>
getDueTasksForSession(sessionID: string, now?: number): Promise<LoopTask[]>
nextDueAt(task: LoopTask, now?: number): Promise<number>
Expand Down Expand Up @@ -138,7 +139,8 @@ export const LOOP_HELP = `/loop — run prompts on a schedule
Usage:
/loop <prompt> Adaptive: runs now, the model picks the next check (fallback 1m–1h)
/loop <interval> <prompt> 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 <prompt> every <interval> Same as above (e.g. "check the deploy every 20m", "every 5 minutes")
/loop Maintenance mode (loop.md is re-read on every run — edits apply next fire)
/loop help Show this help
/proactive ... Full alias of /loop

Expand Down Expand Up @@ -229,14 +231,20 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta
}

if (!trimmed) {
const prompt = inst.loadDefaultPrompt(directory)
const source = inst.loadDefaultPromptSource(directory)
const prompt = source.content
const task = await inst.opts.store.create({
prompt,
mode: "maintenance",
adaptiveMaxMs: inst.opts.adaptiveMaxMs,
directory,
source: "default",
sessionID,
// File-backed maintenance re-reads loop.md on every fire, so edits
// take effect on the next trigger (Claude Code behavior).
...(source.path
? { loopFilePath: source.path, lastContentHash: hashContent(prompt) }
: {}),
})
// Run the maintenance prompt immediately in this turn (matching
// Adaptive's run-now behavior), then re-arm on the slow cycle.
Expand Down Expand Up @@ -406,9 +414,10 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta
return lines.join("\n")
},

loadDefaultPrompt(directory) {
// Priority: project > user > built-in default (mirrors Claude Code's
// project `.claude/loop.md` vs user `~/.claude/loop.md`).
/** Resolve the maintenance prompt AND its backing file (when any).
* Priority: project > user > built-in default (mirrors Claude Code's
* project `.claude/loop.md` vs user `~/.claude/loop.md`). */
loadDefaultPromptSource(directory) {
const candidates = [
join(directory, ".opencode", "loop.md"),
join(directory, "loop.md"),
Expand All @@ -418,13 +427,17 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta
if (existsSync(p)) {
try {
const content = readFileSync(p, "utf-8").trim()
if (content) return content
if (content) return { path: p, content }
} catch {
// ignore
}
}
}
return DEFAULT_MAINTENANCE_PROMPT
return { path: null, content: DEFAULT_MAINTENANCE_PROMPT }
},

loadDefaultPrompt(directory) {
return inst.loadDefaultPromptSource(directory).content
},

async getDueTasks(now: number = Date.now()) {
Expand Down Expand Up @@ -483,13 +496,44 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta
inst.inflight.add(task.id)
try {
const sessionID = task.sessionID
const text =
task.mode === "adaptive"
? buildAdaptiveExecutionPrompt(task, {
minMs: inst.opts.adaptiveMinMs,
maxMs: inst.opts.adaptiveMaxMs,
})
: buildFixedExecutionPrompt(task)
let text: string
if (task.mode === "adaptive") {
text = buildAdaptiveExecutionPrompt(task, {
minMs: inst.opts.adaptiveMinMs,
maxMs: inst.opts.adaptiveMaxMs,
})
} else if (task.mode === "maintenance" && task.loopFilePath) {
// File-backed maintenance re-reads loop.md at fire time (Claude
// Code behavior): edited file → inject the full new content;
// unchanged → short cache-friendly reminder; deleted → no-op tick.
let content: string | null = null
try {
if (existsSync(task.loopFilePath)) {
content = readFileSync(task.loopFilePath, "utf-8").trim() || null
}
} catch {
content = null
}
if (content === null) {
await logger("info", "loop.md missing or empty; skipping maintenance tick", {
taskId: task.id,
path: task.loopFilePath,
})
await inst.opts.store.logFire(task, false)
return false
}
const hash = hashContent(content)
if (hash === task.lastContentHash) {
text = buildMaintenanceExecutionPrompt(task, null)
} else {
text = buildMaintenanceExecutionPrompt(task, content)
task.lastContentHash = hash
task.prompt = content
await inst.opts.store.touch(task.id)
}
} else {
text = buildFixedExecutionPrompt(task)
}
const directory = task.directory || ctx?.directory || process.cwd()
const client = ctx?.client

Expand Down
11 changes: 11 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ interface LoopStoreInstance {
getDueTasksForSession(sessionID: string, now?: number): Promise<LoopTask[]>
getOrphanedTasks(): LoopTask[]
markFired(id: string, nextDueAt?: number): Promise<LoopTask | null>
/** Persist in-place mutations made by the caller (e.g. lastContentHash). */
touch(id: string): Promise<LoopTask | null>
reschedule(id: string, nextDueAt: number): Promise<LoopTask | null>
setFixed(
id: string,
Expand Down Expand Up @@ -300,6 +302,8 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI
// Only present on one-shot tasks — keeps persisted JSON stable for
// tasks that never use the field.
...(input.once ? { once: true as const } : {}),
...(input.loopFilePath ? { loopFilePath: input.loopFilePath } : {}),
...(input.lastContentHash ? { lastContentHash: input.lastContentHash } : {}),
ownerPid: identity.pid,
ownerStartedAt: identity.startedAt,
}
Expand Down Expand Up @@ -365,6 +369,13 @@ export function LoopStore(this: unknown, options?: LoopStoreOptions): LoopStoreI
await inst.persist()
return task
},
touch: async (id) => {
const task = inst.get(id)
if (!task) return null
dirtyIds.add(id)
await inst.persist()
return task
},
reschedule: async (id, nextDueAt) => {
const task = inst.get(id)
if (!task) return null
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export interface LoopTask {
ownerPid?: number
/** Owner process start time (epoch ms), guards against pid reuse. */
ownerStartedAt?: number
/** Maintenance tasks only: the loop.md file this task re-reads on every
* fire. When absent, the task fires its stored prompt snapshot. */
loopFilePath?: string
/** Hash of the loop.md content last injected — unchanged content fires a
* short cache-friendly reminder instead of the full text. */
lastContentHash?: string
}

export interface LoopConfig {
Expand Down Expand Up @@ -89,6 +95,10 @@ export interface CreateTaskInput {
sessionID: string
/** One-shot task (fixed mode only): auto-cancel after the first successful fire. */
once?: boolean
/** Maintenance tasks only: re-read this loop.md file on every fire. */
loopFilePath?: string
/** Hash of the loop.md content captured at creation. */
lastContentHash?: string
}

export interface FireResult {
Expand Down
44 changes: 43 additions & 1 deletion tests/cron-parser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,46 @@ test("format ms back to readable", () => {
assert.equal(p.format(3_600_000), "1h")
assert.equal(p.format(7_200_000), "2h")
assert.equal(p.format(86_400_000), "1d")
})
})
// --- trailing "every" clause extraction (Claude Code rule 2) ---

test("extractInterval: trailing every clause becomes a fixed interval", async () => {
const cron = new CronParser()
const cases = [
["check the deploy every 20m", 20 * 60_000, "check the deploy"],
["check the deploy every 30s", 30_000, "check the deploy"],
["check CI every 5 minutes", 5 * 60_000, "check CI"],
["look for failures every 2 hours", 2 * 3_600_000, "look for failures"],
["daily report every 1 day", 86_400_000, "daily report"],
["ping every 1.5h", 5_400_000, "ping"],
["CHECK EVERY 10M", 600_000, "CHECK"],
]
for (const [input, ms, rest] of cases) {
const r = cron.extractInterval(input)
assert.ok(r.interval, `expected interval for: ${input}`)
assert.equal(r.interval.ms, ms, input)
assert.equal(r.rest, rest, input)
}
})

test("extractInterval: trailing every without a time expression does not match", async () => {
const cron = new CronParser()
for (const input of ["check every PR", "review every merge request", "every 20m ago check"]) {
const r = cron.extractInterval(input)
assert.equal(r.interval, null, input)
}
// every in the middle is prompt text, not a schedule
const mid = cron.extractInterval("check every 2m worth of logs")
assert.equal(mid.interval, null)
// empty prompt before the clause: interval extracted, rest empty
const bare = cron.extractInterval("every 5m")
assert.equal(bare.interval.ms, 300_000)
assert.equal(bare.rest, "")
})

test("extractInterval: leading token wins over a trailing every clause", async () => {
const cron = new CronParser()
const r = cron.extractInterval("5m check the deploy every 2m")
assert.equal(r.interval.ms, 300_000)
assert.equal(r.rest, "check the deploy every 2m", "trailing clause stays in the prompt verbatim")
})
4 changes: 2 additions & 2 deletions tests/package-exports.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
)

test("publishes the 0.8.0 release", () => {
assert.equal(packageJson.version, "0.8.0")
test("publishes the 0.8.1 release", () => {
assert.equal(packageJson.version, "0.8.1")
})

test("publishes explicit server and TUI plugin entrypoints", () => {
Expand Down
Loading
Loading