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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
41 changes: 36 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,31 @@

[![npm version](https://img.shields.io/npm/v/opencode-plugin-loop.svg)](https://www.npmjs.com/package/opencode-plugin-loop)
[![npm downloads](https://img.shields.io/npm/dm/opencode-plugin-loop.svg)](https://www.npmjs.com/package/opencode-plugin-loop)
[![CI](https://github.com/jkrandom-sudo/opencode-plugin-loop/actions/workflows/ci.yml/badge.svg)](https://github.com/jkrandom-sudo/opencode-plugin-loop/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/jkrandom-sudo/opencode-plugin-loop/blob/main/LICENSE)

A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Claude Code's `/loop`. Each `/loop` task is bound to the session that created it — never leaks to other sessions.

> **Upgrading to 0.4.0?** Two behavior changes to know about: (1) since 0.3.0, tasks die with the opencode process by default (`ephemeralTasks: false` restores persistence) — the upgrade drops the pre-0.3.0 `tasks.json` once; (2) scheduling-like input that used to silently create an Adaptive task (cron syntax, bare intervals like `/loop 5m`, unknown flags) now returns an explicit error pointing at `/loop help`.

## Features

- **`/loop 5m <prompt>`** — fixed interval (s/m/h/d supported)
- **`/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
- **`/loop`** — bare: read `.opencode/loop.md` or run built-in maintenance, immediately
- **`/loop 30s --once <prompt>`** — one-shot: fires once, then auto-cancels
- **`/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 or fire them
- **Subcommands** — `list | status | cancel | pause | resume | stop-all` (session-scoped; add `--all` to cross sessions)
- **Internal ticker** — 5s loop drives task firing (no longer depends on `session.idle` events)
- **Single-leader instance lock** — when several plugin instances share one `tasks.json` (case-variant plugin paths, per-command `opencode run` instances), only the leader fires; merge-writes prevent task loss
- **Inflight guard** — double-set at ticker and `fireTask` level prevents double-firing even if opencode hot-reloads the plugin
- **Persistent tasks** — survive session restarts; auto-migrated (tasks without `sessionID` are dropped on load)
- **Wall-clock scheduling** — fixed tasks anchor to fire start; model-turn duration never inflates the interval
- **Ephemeral lifecycle (default)** — tasks die with the OpenCode process and are dropped on the next start, matching Claude Code's `/loop`. Set `ephemeralTasks: false` to persist tasks across process restarts
- **Auto-cleanup on `session.deleted`** — all tasks for that session are cancelled automatically
- **Configurable Jitter** — deterministic Fixed-task offset, controllable per command, tool call, or programmatic default
- **Auto-expire** — tasks older than 7 days are removed on load
- **Auto-expire** — tasks idle for more than 7 days are removed on load (active tasks never expire)
- **Max 50 concurrent tasks**
- **LLM-callable tools** — `loop_schedule`, `loop_status` (session-bound by default)
- **Interactive Loop results** — `/loop` results open in a dedicated native dialog instead of writing over the prompt
Expand Down Expand Up @@ -59,6 +66,14 @@ opencode plugin opencode-plugin-loop --global --force

The `--force` flag replaces the installed plugin version and refreshes both global config entries without requiring a version-number change. Restart OpenCode after the command completes.

**Upgrade self-check.** opencode keeps its own plugin package cache at `~/.cache/opencode/packages`, and `--force` does not always refresh it. If an upgrade reports success but behavior does not change (e.g. `npm view opencode-plugin-loop version` disagrees with what you see), clear the cache and restart:

```bash
rm -rf ~/.cache/opencode/packages/opencode-plugin-loop*
```

Then verify with `/loop help` — new flags and subcommands show up there immediately.

### Option 2: Manual configuration

Add the same package name to the `plugin` array in both configuration files.
Expand All @@ -70,7 +85,7 @@ Server config (`~/.config/opencode/opencode.json`):
"plugin": ["opencode-plugin-loop"],
"command": {
"loop": {
"description": "定时重复执行 prompt。可选间隔: s/m/h/d。子命令: list | status | cancel <id> | pause <id> | resume <id> | stop-all(加 --all 跨 session)",
"description": "Run prompts on a schedule. Intervals: s/m/h/d. Subcommands: help | list | status | cancel <id> | pause <id> | resume <id> | stop-all (add --all to cross sessions)",
"template": "$ARGUMENTS",
"agent": "build"
}
Expand Down Expand Up @@ -107,6 +122,7 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r
/loop 30s ping the health endpoint
/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
```

Fixed tasks use deterministic Jitter by default for backward compatibility. Add
Expand Down Expand Up @@ -143,19 +159,34 @@ address each one. If everything is green, say so in one line.
All subcommands are **session-scoped by default**. Add `--all` to operate across all sessions.

```
/loop help # full usage, flags, and examples
/loop list # show tasks in current session
/loop list --all # show all sessions (with [s:xxxx] tags)
/loop status # alias for list
/loop cancel <taskId> # cancel one task in current session
/loop cancel <taskId> --all # override scope
/loop pause <taskId> # pause one
/loop resume <taskId> # resume one (re-arms fixed interval)
/loop resume <taskId> # resume one (re-arms per mode)
/loop stop-all # cancel all tasks in current session
/loop stop-all --all # cancel ALL tasks across sessions
```

If you try `cancel <id>` for a task owned by another session, you'll get a refusal with a hint to add `--all`. The same strict scoping applies to `loop_schedule` and `loop_status` tools.

### Migrating from Claude Code

| Claude Code `/loop` | opencode-plugin-loop |
|---|---|
| `/loop 5m <prompt>` | identical |
| `/loop <prompt>` (self-paced) | Adaptive: runs now, model picks the next check (fallback 1m–1h) |
| 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>` |
| jobs die when the session ends | same default since 0.3.0 (`ephemeralTasks: false` opts out) |
| cron expressions (`*/5 * * * *`) | not supported — use `5m` form (explicit error) |

Two behavioral differences worth knowing: tasks only fire for the **currently active session** (switch sessions and the others wait; switch back and they catch up once), and fixed tasks fire on a 5-second ticker rather than exact wall-clock cron times (up to one ticker period late).

### Interactive result dialog

Every `/loop` command result opens in a separate native OpenCode dialog. It keeps task output away from the prompt and provides:
Expand Down
4 changes: 2 additions & 2 deletions commands/loop.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: 定时重复执行 prompt。自然语言 Adaptive 请求会立即执行并判断后续调度;显式间隔支持 --jitter=true|false。子命令加 --all 可跨 session。
argument-hint: "[5m] [--jitter=true|false] [prompt text... | list | cancel <id> | pause <id> | resume <id> | stop-all] [--all]"
description: 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. Subcommands add --all to cross sessions. See /loop help.
argument-hint: "[5m] [--jitter=true|false] [--once] [prompt text... | help | list | cancel <id> | pause <id> | resume <id> | stop-all] [--all]"
agent: build
---

Expand Down
2 changes: 1 addition & 1 deletion examples/loop.md.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ quiet, say so in one line.
After completing the work, call:
loop_schedule({ action: "cancel", taskId: "<your task id>" })
to end the loop, OR
loop_schedule({ action: "reschedule", taskId: "<your task id>", nextDueAtMs: Date.now() + 5*60*1000 })
loop_schedule({ action: "reschedule", taskId: "<your task id>", delayMs: 5*60*1000 })
to continue checking.
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.3.0",
"version": "0.4.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
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import type { Plugin, Hooks, PluginModule } from "@opencode-ai/plugin"
import { LoopStore } from "./store.js"
import { InstanceLock } from "./instance-lock.js"
import { Scheduler } from "./scheduler.js"
import { CronParser } from "./cron-parser.js"
import { Jitter } from "./jitter.js"
Expand All @@ -47,12 +48,13 @@ const DEFAULT_CONFIG: Required<LoopConfig> = {
tickerIntervalMs: 5_000,
defaultJitterEnabled: true,
ephemeralTasks: true,
instanceLock: true,
}

function commandAction(args: string): string {
const head = args.trim().split(/\s+/, 1)[0]?.toLowerCase()
if (!head) return "maintenance"
if (["list", "status", "cancel", "stop", "pause", "resume", "stop-all"].includes(head)) {
if (["list", "status", "cancel", "stop", "pause", "resume", "stop-all", "help"].includes(head)) {
return head
}
return "schedule"
Expand Down Expand Up @@ -107,9 +109,15 @@ export const LoopPlugin: Plugin = async (ctx) => {

// Internal ticker: every 5s, fire any due tasks whose sessionID matches the active session.
// This replaces the old session.idle-event-driven firing and runs even when no user input.
// Only the lock leader fires: other instances sharing this tasks.json (B1)
// keep their tickers idle but may take over if the leader goes stale.
const lock = InstanceLock({ storageDir, logger })
const lockEnabled = config.instanceLock
if (lockEnabled) lock.start()
const inflight = new Set<string>()
const ticker = setInterval(async () => {
try {
if (lockEnabled && !lock.isLeader()) return
if (!activeSessionID) return
const due = await scheduler.getDueTasksForSession(activeSessionID)
if (due.length === 0) return
Expand Down Expand Up @@ -188,6 +196,7 @@ export const LoopPlugin: Plugin = async (ctx) => {
;(hooks as any)._ticker = ticker
hooks.dispose = async () => {
clearInterval(ticker)
lock.stop()
}

return hooks
Expand All @@ -204,6 +213,7 @@ export default plugin

// ---- Public API exports (for users who want to compose) ----
export { LoopStore } from "./store.js"
export { InstanceLock } from "./instance-lock.js"
export { Scheduler } from "./scheduler.js"
export { CronParser } from "./cron-parser.js"
export { Jitter } from "./jitter.js"
Expand Down
191 changes: 191 additions & 0 deletions src/instance-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* InstanceLock: single-leader election between plugin instances that share one
* tasks.json. opencode can load the same plugin through two case-variant paths
* (macOS case-insensitive FS), and every `opencode run --attach` spawns another
* in-process instance — each with its own ticker. Without coordination every
* instance fires the same due task (B1: duplicate fires + lost writes).
*
* Design:
* - The lock is a DIRECTORY ({storageDir}/loop.lock/) so acquisition is an
* atomic mkdirSync. Inside it, lock.json records the owner.
* - Same-process instances share a pid, so ownership is keyed by a random
* instanceId, not by pid.
* - The leader heartbeats by touching lock.json every heartbeatMs. A follower
* takes over only when the lock is stale (no heartbeat for staleMs) and it
* wins an atomic rename race.
* - Followers keep their ticker running but skip firing; commands and tool
* calls still work because every store write goes through merge-write.
*
* Implementation note: factory pattern (no `this` reliance) so opencode's
* plugin loader can call us with or without `new`.
*/

import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"
import { hostname } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { errorMessage, type LoopLogger } from "./runtime-feedback.js"

export interface InstanceLockOptions {
storageDir: string
/** Injectable for tests; defaults to a random UUID. */
instanceId?: string
/** Lock without a heartbeat for this long is considered abandoned (default 15_000). */
staleMs?: number
/** Heartbeat / takeover-probe interval (default 2_500). */
heartbeatMs?: number
logger?: LoopLogger
/** Injectable clock for tests. */
now?: () => number
}

export interface InstanceLockInstance {
instanceId: string
isLeader(): boolean
/** Begin heartbeat / takeover probing. Safe to call once. */
start(): void
/** Stop probing; release the lock if leader. */
stop(): void
}

interface LockFile {
instanceId: string
pid: number
hostname: string
startedAt: number
}

export function InstanceLock(this: unknown, options: InstanceLockOptions): InstanceLockInstance {
void this
const logger: LoopLogger = options.logger ?? (async () => {})
const now = options.now ?? Date.now
const instanceId = options.instanceId ?? randomUUID()
const staleMs = options.staleMs ?? 15_000
const heartbeatMs = options.heartbeatMs ?? 2_500
const lockDir = join(options.storageDir, "loop.lock")
const lockFile = join(lockDir, "lock.json")

let leading = false
let timer: ReturnType<typeof setInterval> | null = null

const writeLockFile = () => {
const body: LockFile = {
instanceId,
pid: process.pid,
hostname: hostname(),
startedAt: now(),
}
writeFileSync(lockFile, JSON.stringify(body, null, 2), "utf-8")
}

const readLockMtime = (): number | null => {
try {
return statSync(lockFile).mtimeMs
} catch {
return null
}
}

const acquire = (): boolean => {
try {
mkdirSync(lockDir)
writeLockFile()
return true
} catch {
return false
}
}
const tryTakeover = async (): Promise<boolean> => {
if (acquire()) {
await logger("info", "loop instance lock acquired", { instanceId })
return true
}
// Lock held: am I the owner? (e.g. after a same-process reload)
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
if (owner.instanceId === instanceId) return true
} catch {
// Unreadable lock file: fall through to staleness check
}
const mtime = readLockMtime()
const stale = mtime === null || now() - mtime > staleMs
if (!stale) return false
// Abandoned lock: win an atomic rename race before deleting it, so two
// followers cannot both take over.
const graveyard = `${lockDir}.stale.${instanceId}`
try {
renameSync(lockDir, graveyard)
} catch {
return false
}
try {
rmSync(graveyard, { recursive: true, force: true })
} catch {
// Non-fatal: a stale graveyard directory does not block acquisition.
}
const won = acquire()
if (won) {
await logger("info", "loop instance lock taken over from stale owner", { instanceId })
}
return won
}

const tick = async () => {
try {
if (leading) {
// Still mine? A same-process follower may have taken over after
// deciding our heartbeat stopped (e.g. event-loop stall).
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
if (owner.instanceId !== instanceId) {
leading = false
await logger("warn", "loop instance lock lost", { instanceId })
return
}
} catch {
leading = false
await logger("warn", "loop instance lock lost (unreadable)", { instanceId })
return
}
try {
const at = new Date(now())
utimesSync(lockFile, at, at)
} catch (err) {
await logger("warn", "loop lock heartbeat failed", { error: errorMessage(err) })
}
return
}
leading = await tryTakeover()
} catch (err) {
await logger("warn", "loop instance lock tick failed", { error: errorMessage(err) })
}
}

const inst: InstanceLockInstance = {
instanceId,
isLeader: () => leading,
start: () => {
if (timer) return
void tick()
timer = setInterval(() => void tick(), heartbeatMs)
// Never keep the process alive just for the lock.
if (typeof timer.unref === "function") timer.unref()
},
stop: () => {
if (timer) {
clearInterval(timer)
timer = null
}
if (leading) {
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
if (owner.instanceId === instanceId) rmSync(lockDir, { recursive: true, force: true })
} catch {
// Lock already gone or unreadable — nothing to release.
}
leading = false
}
},
}
return inst
}
Loading
Loading