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
60 changes: 25 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

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)
Expand All @@ -17,10 +15,10 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla
- **`/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 | 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)
- **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-session scoping** — tasks are bound to a `sessionID`; other sessions never see, fire, or manage them
- **Subcommands** — `list | status | cancel | stop | pause | resume | stop-all` (all scoped to the current session; bare `stop` cancels every task in the session)
- **Internal ticker** — 5s loop drives task firing
- **Prompt fidelity** — flags (`--once`, `--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
Expand All @@ -29,7 +27,7 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla
- **Configurable Jitter** — deterministic Fixed-task offset, controllable per command, tool call, or programmatic default
- **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)
- **LLM-callable tools** — `loop_schedule`, `loop_status` (scoped to the calling session)
- **Inline results, Claude Code style** — every `/loop` result (create, list, cancel, pause, resume, stop-all, failures) is presented by the model directly in the conversation, in the user's own language — task lists render as a markdown table. No dialogs, no toasts

## Requirements
Expand Down Expand Up @@ -80,7 +78,7 @@ Server config (`~/.config/opencode/opencode.json`):
"plugin": ["opencode-plugin-loop"],
"command": {
"loop": {
"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)",
"description": "Run prompts on a schedule. Intervals: s/m/h/d. Subcommands: help | list | status | cancel <id> | pause <id> | resume <id> | stop-all (all scoped to the current session)",
"template": "$ARGUMENTS",
"agent": "build"
}
Expand Down Expand Up @@ -120,10 +118,10 @@ 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
```

Fixed tasks use deterministic Jitter by default for backward compatibility. Add
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
prompt word — including text that looks like `--once`, `--all`, or `--jitter=*` —
prompt word — including text that looks like `--once` 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:

Expand All @@ -148,7 +146,7 @@ The natural-language form runs the request immediately in the current model turn

Adaptive-to-Fixed conversion defaults to `jitterEnabled: false`, so an explicit cadence remains exact.

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. The legacy absolute `nextDueAtMs` remains supported, but passing it together with `delayMs` returns an error without changing the task.
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:
Expand All @@ -160,25 +158,21 @@ address each one. If everything is green, say so in one line.

### Subcommands

All subcommands are **session-scoped by default**. Add `--all` to operate across all sessions.
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.

```
/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 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
/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.
Trying to manage a task owned by another session reports "No task `<id>` in this session" — switch to that session to manage it. The same strict scoping applies to the `loop_schedule` and `loop_status` tools.

### Migrating from Claude Code

Expand All @@ -189,7 +183,7 @@ If you try `cancel <id>` for a task owned by another session, you'll get a refus
| 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) |
| jobs die when the session ends | same default (`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).
Expand All @@ -207,7 +201,7 @@ No dialogs, no toasts — the conversation is the only output surface.

### Programmatic (LLM tools)

The plugin registers two LLM-callable tools. Both are session-bound by default; pass `all: true` to cross.
The plugin registers two LLM-callable tools. Both are scoped to the calling session.

```typescript
loop_schedule({
Expand All @@ -220,7 +214,7 @@ loop_schedule({
loop_schedule({
action: "cancel",
taskId: "abc12345",
// refuses if taskId belongs to another session (pass all: true to override)
// only works for tasks created in the calling session
})

loop_schedule({
Expand All @@ -236,8 +230,7 @@ loop_schedule({
jitterEnabled: false, // default for Adaptive-to-Fixed conversion
})

loop_status({}) // current session only
loop_status({ all: true }) // all sessions
loop_status({}) // current session
```

## Configuration
Expand All @@ -262,13 +255,10 @@ 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.
other **live** OpenCode processes in the same project are left untouched, and
each process fires only its own tasks. Same-process plugin reloads keep their
tasks. Set `ephemeralTasks: false` in the plugin options to persist tasks
across process restarts.

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 @@ -277,8 +267,8 @@ is not added to a model-selected Adaptive time.
For programmatic composition, `LoopConfig.defaultJitterEnabled` controls newly
created Fixed tasks and defaults to `true`. An explicit command
`--jitter=true|false` or tool argument `jitterEnabled` overrides that default.
Existing persisted Fixed tasks without a `jitterEnabled` field retain the legacy
Jitter-on behavior. Because the ticker checks every 5 seconds, actual prompt
Persisted Fixed tasks without a `jitterEnabled` field are treated as
Jitter-on. Because the ticker checks every 5 seconds, actual prompt
injection can occur up to one ticker period after an exact due time.

## Per-session architecture
Expand All @@ -292,7 +282,7 @@ 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), 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 |
| 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 left untouched; with it disabled, tasks resume as before |
| Old `tasks.json` without `sessionID` | Dropped on load (with log message) |

## Storage
Expand All @@ -303,15 +293,15 @@ Tasks persist to `.opencode/cache/loop/tasks.json` (per project). Fire history i

### Package entrypoints

Current releases expose separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode installs that auto-load both keep working (the TUI entrypoint is a no-op since results are presented inline). The root export remains the v1-compatible server module for backward compatibility. Programmatic consumers should use the named factory:
The package exposes separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode installs that auto-load both keep working (the TUI entrypoint is a no-op since results are presented inline). The root export is the same server module. Programmatic consumers should use the named factory:

```typescript
import { LoopPlugin } from "opencode-plugin-loop"
```

### Task lines overlap the input area
### Results presentation

Older releases wrote `/loop` results directly to the terminal or into native dialogs/toasts. Upgrade to the current release: every result is presented inline by the model, and runtime diagnostics go to OpenCode's structured application log.
Every `/loop` result is presented inline by the model — nothing is written directly to the terminal, and no dialogs or toasts are used. Runtime diagnostics go to OpenCode's structured application log.

Also make sure the plugin is installed from only one source. OpenCode loads npm plugins from `opencode.json` and copied plugins under `~/.config/opencode/plugins/` independently, even when they have the same package name.

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: 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]"
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. 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
---

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.3",
"version": "0.7.4",
"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: 6 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
* /loop check the deploy status — adaptive interval (1min–1hr)
* /loop — bare: read .opencode/loop.md or default maintenance
*
* Subcommands (all session-scoped; add `--all` to cross sessions):
* /loop list | status [--all] — show tasks
* /loop cancel | stop <id> [--all] — cancel one
* /loop pause <id> [--all] — pause one
* /loop resume <id> [--all] — resume one
* /loop stop-all [--all] — cancel all
* Subcommands (all scoped to the current session):
* /loop list | status — show tasks
* /loop cancel | stop <id> — cancel one
* /loop pause <id> — pause one
* /loop resume <id> — resume one
* /loop stop | stop-all — cancel all tasks in this session
*
* Per-session architecture:
* - chat.message hook tracks the current active sessionID
Expand Down
Loading
Loading