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
42 changes: 29 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla
- **`/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)
- **Subcommands** — `list | status | cancel | stop | pause | resume | stop-all` (session-scoped; add `--all` to cross sessions; bare `stop` cancels every task in scope)
- **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
- **Prompt fidelity** — flags (`--once`, `--all`, `--jitter=*`) are only recognized before the prompt begins; `--` forces the rest to be treated as prompt text verbatim, and whitespace/newlines are preserved
- **Per-process instance coordination** — plugin instances inside one process (case-variant plugin paths, per-command `opencode run` instances) elect a single leader so tasks never double-fire; a second OpenCode process in the same project fires its own tasks independently, and merge-writes prevent task loss
- **Inflight guard** — double-set at ticker and `fireTask` level prevents double-firing even if opencode hot-reloads the plugin
- **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
Expand Down Expand Up @@ -121,7 +122,16 @@ Re-run `npm run build` after editing `src/`, then restart OpenCode to load the r

Fixed tasks use deterministic Jitter by default for backward compatibility. Add
`--jitter=false` for an exact interval or `--jitter=true` to enable it explicitly.
The flag is scheduling metadata and is removed from the repeated prompt.
Flags are only recognized **before the prompt begins**: anything after the first
prompt word — including text that looks like `--once`, `--all`, or `--jitter=*` —
is part of the prompt and is preserved verbatim (whitespace and newlines
included). Use `--` to force everything after it to be treated as prompt text:

```
/loop 1m --once remind me to stretch # --once is a flag (prefix position)
/loop 1m explain what --once means # --once is prompt text, kept verbatim
/loop 1m -- --jitter=false is a flag too # -- forces the rest to be prompt
```

### Adaptive interval (LLM decides next fire time)
```
Expand Down Expand Up @@ -159,6 +169,9 @@ All subcommands are **session-scoped by default**. Add `--all` to operate across
/loop status # alias for list
/loop cancel <taskId> # cancel one task in current session
/loop cancel <taskId> --all # override scope
/loop stop <taskId> # alias for cancel
/loop stop # cancel ALL tasks in current session
/loop stop --all # cancel ALL tasks across sessions
/loop pause <taskId> # pause one
/loop resume <taskId> # resume one (re-arms per mode)
/loop stop-all # cancel all tasks in current session
Expand Down Expand Up @@ -245,14 +258,17 @@ The built-in runtime defaults are:
| Ephemeral tasks | enabled |

**Ephemeral lifecycle.** With `ephemeralTasks` enabled (the default), every
`tasks.json` records the pid and start time of the process that wrote it. On
load, tasks written by any other process — e.g. after OpenCode exits and
restarts — are dropped, so loop tasks never outlive the process that created
them (the same lifecycle as Claude Code's `/loop`). Same-process plugin reloads
keep their tasks. Pass `{ ephemeralTasks: false }` in the plugin options to
restore the previous behavior of persisting tasks across process restarts. Note
that upgrading from a release without process-identity tracking drops the
existing `tasks.json` once, since it carries no trusted writer identity.
task records its owning process (`ownerPid` + start time) in `tasks.json`. On
load, tasks whose owner process is confirmed dead — e.g. after that OpenCode
process exits — are dropped, so loop tasks never outlive the process that
created them (the same lifecycle as Claude Code's `/loop`). Tasks owned by
other **live** OpenCode processes in the same project are kept: they remain
visible and manageable via `--all`, and each process fires only its own tasks.
Same-process plugin reloads keep their tasks. Pass `{ ephemeralTasks: false }`
in the plugin options to restore the previous behavior of persisting tasks
across process restarts. Note that upgrading from a release without per-task
owner tracking drops tasks that carry no owner identity once, since their
writer cannot be verified.

Adaptive minimum and maximum delays are persisted on each task. The random fallback
and any model-requested `reschedule` are both constrained by that task's bounds. Jitter
Expand All @@ -276,12 +292,12 @@ Each `/loop` task carries a `sessionID` field:
| User runs `/loop` in session B | Session B becomes active; A's task waits |
| `session.deleted` for session A | All A's tasks cancelled automatically |
| Plugin reload (`opencode` hot-reload) | Old tickers stop, new ticker starts; in-flight tasks guarded by `inflight` Set |
| Process restart (new pid) | With `ephemeralTasks` enabled (default), all tasks from the previous process are dropped on load; with it disabled, tasks resume as before |
| Process restart (new pid) | With `ephemeralTasks` enabled (default), tasks whose owner process is dead are dropped on load, while tasks owned by other live processes are kept; with it disabled, tasks resume as before |
| Old `tasks.json` without `sessionID` | Dropped on load (with log message) |

## Storage

Tasks persist to `.opencode/cache/loop/tasks.json` (per project). Fire history is logged to `history.log` next to the store. The state file also records the writer's `pid` and `startedAt`, which the ephemeral lifecycle uses to detect process restarts.
Tasks persist to `.opencode/cache/loop/tasks.json` (per project). Fire history is logged to `history.log` next to the store. Each task records its owner's `ownerPid` and `ownerStartedAt`, which the ephemeral lifecycle uses to tell dead-process leftovers apart from tasks owned by other live OpenCode processes.

## Troubleshooting

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.7.1",
"version": "0.7.2",
"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
30 changes: 24 additions & 6 deletions src/cron-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ const UNIT_TO_MS: Record<string, number> = {
}
const MIN_INTERVAL_MS = 1_000

/**
* Single validator shared by the slash parser, the LLM tool `create`, and
* `set_fixed` — all entries must accept/reject the same fixed intervals.
*/
export function validateFixedInterval(
ms: unknown
): { ok: true; ms: number } | { ok: false; error: string } {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return { ok: false, error: "intervalMs must be a finite number" }
}
if (ms < MIN_INTERVAL_MS) {
return { ok: false, error: `intervalMs must be at least ${MIN_INTERVAL_MS}ms` }
}
return { ok: true, ms }
}

export interface CronParserInstance {
parse(input: string): ParsedInterval | null
extractInterval(text: string): { interval: ParsedInterval | null; rest: string }
Expand Down Expand Up @@ -62,17 +78,19 @@ export function CronParser(this: unknown): CronParserInstance {
}
},

/** Try to extract an interval from a user command like "5m check deploy" */
/** 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. */
extractInterval(text: string) {
const tokens = text.trim().split(/\s+/)
if (tokens.length === 0) return { interval: null, rest: text }

const first = tokens[0]
const trimmed = text.trim()
if (!trimmed) return { interval: null, rest: text }
const firstMatch = /^\S+/.exec(trimmed)
const first = firstMatch?.[0] ?? ""
const parsed = inst.parse(first)
if (parsed) {
return {
interval: parsed,
rest: tokens.slice(1).join(" "),
rest: trimmed.slice(first.length).replace(/^\s+/, ""),
}
}
return { interval: null, rest: text }
Expand Down
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,23 @@ 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.
// Firing requires lock.shouldFire(): the lock serializes same-process plugin
// instances only — when the lock is held by ANOTHER process, that process
// fires its own tasks and we fire ours (owner-pid filter below).
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 (lockEnabled && !lock.shouldFire()) return
if (!activeSessionID) return
const due = await scheduler.getDueTasksForSession(activeSessionID)
if (due.length === 0) return
for (const task of due) {
if (task.sessionID !== activeSessionID) continue
// Tasks created by another live process are fired by that process.
if (task.ownerPid !== undefined && task.ownerPid !== process.pid) continue
if (inflight.has(task.id)) continue
inflight.add(task.id)
try {
Expand Down
15 changes: 15 additions & 0 deletions src/instance-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface InstanceLockOptions {
export interface InstanceLockInstance {
instanceId: string
isLeader(): boolean
/**
* Whether this instance's ticker may fire tasks. The lock only serializes
* instances inside ONE process (case-variant plugin paths); when the lock
* is held by a DIFFERENT process, that process fires its own tasks and we
* fire ours — so anything but "same-pid foreign leader" allows firing.
*/
shouldFire(): boolean
/** Begin heartbeat / takeover probing. Safe to call once. */
start(): void
/** Stop probing; release the lock if leader. */
Expand All @@ -66,6 +73,7 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
const lockFile = join(lockDir, "lock.json")

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

const writeLockFile = () => {
Expand All @@ -90,6 +98,7 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
try {
mkdirSync(lockDir)
writeLockFile()
ownerPid = process.pid
return true
} catch {
return false
Expand All @@ -103,9 +112,11 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
// Lock held: am I the owner? (e.g. after a same-process reload)
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
ownerPid = owner.pid
if (owner.instanceId === instanceId) return true
} catch {
// Unreadable lock file: fall through to staleness check
ownerPid = null
}
const mtime = readLockMtime()
const stale = mtime === null || now() - mtime > staleMs
Expand Down Expand Up @@ -137,13 +148,15 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
// deciding our heartbeat stopped (e.g. event-loop stall).
try {
const owner = JSON.parse(readFileSync(lockFile, "utf-8")) as LockFile
ownerPid = owner.pid
if (owner.instanceId !== instanceId) {
leading = false
await logger("warn", "loop instance lock lost", { instanceId })
return
}
} catch {
leading = false
ownerPid = null
await logger("warn", "loop instance lock lost (unreadable)", { instanceId })
return
}
Expand All @@ -164,6 +177,7 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
const inst: InstanceLockInstance = {
instanceId,
isLeader: () => leading,
shouldFire: () => leading || ownerPid !== process.pid,
start: () => {
if (timer) return
void tick()
Expand All @@ -185,6 +199,7 @@ export function InstanceLock(this: unknown, options: InstanceLockOptions): Insta
}
leading = false
}
ownerPid = null
},
}
return inst
Expand Down
Loading
Loading