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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla

## Features

- **`/loop 5m <prompt>`** β€” fixed interval (s/m/h/d supported)
- **`/loop 5m <prompt>`** β€” fixed interval (s/m/h/d supported), runs immediately on creation, then repeats on schedule
- **`/loop <prompt>`** β€” 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 <prompt>`** β€” 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<user>/.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,
Expand Down Expand Up @@ -178,8 +184,9 @@ 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 |
| `/loop 5m <prompt>` | identical β€” runs immediately on creation, then repeats |
| `/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` |
| `--cancel`, `--list`, `--stop` | accepted β€” mapped to `cancel`, `list`, `stop` |
| one-off reminder ("in 30m tell me X") | `/loop 30s --once <prompt>` |
Expand Down
7 changes: 7 additions & 0 deletions commands/proactive.md
Original file line number Diff line number Diff line change
@@ -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 <id> | pause <id> | resume <id> | stop-all]"
agent: build
---

$ARGUMENTS
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -73,7 +73,8 @@
"opencode": {
"plugin": true,
"commands": [
"commands/loop.md"
"commands/loop.md",
"commands/proactive.md"
]
}
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,16 @@ 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
}
},

"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)
},
}
Expand Down
17 changes: 17 additions & 0 deletions src/runtime-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 30 additions & 6 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,9 +137,10 @@ 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)
/loop Maintenance mode (uses .opencode/loop.md when present)
/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 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
Expand Down Expand Up @@ -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}\``,
}
}

Expand Down Expand Up @@ -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)) {
Expand Down
47 changes: 43 additions & 4 deletions tests/integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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 {
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
}
})
11 changes: 9 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.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", () => {
Expand Down Expand Up @@ -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",
])
})
3 changes: 2 additions & 1 deletion tests/per-session.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions tests/run-mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
62 changes: 61 additions & 1 deletion tests/scheduler.test.mjs
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 })
}
})
Loading