Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c6cafd8
feat: add explicit Loop schedule syntax
ByBrawe Aug 22, 2026
f207fea
refactor: centralize Loop due scheduling policy
ByBrawe Aug 22, 2026
2aed61e
feat: add throttled scheduler deferral diagnostics
ByBrawe Aug 22, 2026
9534af5
feat: detect dedicated Goal continuation conflicts
ByBrawe Aug 22, 2026
898f837
feat: add Loop session and schedule diagnostics
ByBrawe Aug 22, 2026
111fb62
feat: normalize idle and timed Loop schedules
ByBrawe Aug 22, 2026
153c6a2
refactor: drive scheduler from shared due policy
ByBrawe Aug 22, 2026
9f65b70
refactor: share due policy with scheduler
ByBrawe Aug 22, 2026
b4b88c2
fix: recover stale busy before first Loop run
ByBrawe Aug 22, 2026
bf16b49
docs: expose scheduling state and orphan diagnostics
ByBrawe Aug 22, 2026
f9d1072
test: cover idle shorthand and timed schedule grammar
ByBrawe Aug 22, 2026
5d6ba3d
test: cover unified Loop due policy
ByBrawe Aug 22, 2026
aaf421c
test: cover human Loop schedule syntax
ByBrawe Aug 22, 2026
07616e6
test: cover pre-first-run stale busy recovery
ByBrawe Aug 22, 2026
69bb99a
test: wire scheduling regressions into package checks
ByBrawe Aug 22, 2026
4127b7a
test: update Loop scheduling diagnostics expectations
ByBrawe Aug 22, 2026
2744c76
feat: make short continue prompts project-aware
ByBrawe Aug 22, 2026
2477bf3
feat: enrich shorthand continuation prompts
ByBrawe Aug 22, 2026
780cc1c
docs: clarify idle, recurring, and delayed Loop syntax
ByBrawe Aug 22, 2026
847437c
docs: define Loop scheduling semantics
ByBrawe Aug 22, 2026
71d415a
fix: require stale busy age before idle recovery
ByBrawe Aug 22, 2026
13e3dc8
test: include continuation helper in syntax checks
ByBrawe Aug 22, 2026
935b8c8
docs: rewrite Loop scheduling and continuation guide
ByBrawe Aug 22, 2026
0fb6d57
fix: make delayed one-shot syntax authoritative
ByBrawe Aug 22, 2026
b35ee85
test: align comprehensive parser contract with idle shorthand
ByBrawe Aug 22, 2026
b3a63d5
test: exercise durationless devam et on real OpenCode host
ByBrawe Aug 22, 2026
6db3da2
ci: add one-shot idle Loop bundle sync
ByBrawe Aug 22, 2026
0c771f3
build: sync idle Loop stable bundle
Aug 22, 2026
def8db9
ci: remove one-shot idle Loop bundle sync
ByBrawe Aug 22, 2026
4334049
ci: require committed stable bundle to match source
ByBrawe Aug 22, 2026
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
5 changes: 4 additions & 1 deletion .github/workflows/bundle-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Build a single-file stable plugin from source
run: npm run build:plugin

- name: Require committed stable bundle to match modular source
run: git diff --exit-code -- src/index.js

- name: Verify generated entry syntax and import
run: |
node --check src/index.js
Expand All @@ -66,4 +69,4 @@ jobs:
run: npm ci

- name: Run generated-bundle regression suite
run: npm test
run: npm test
480 changes: 197 additions & 283 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion commands/loop.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Start an OpenCode auto-continue loop. Usage: /loop 5m <task>
description: "Auto-continue or schedule work. /loop <task> = every idle; /loop every 5m <task> = recurring; /loop after 5m <task> = once."
agent: opencode-loop-local
---

Expand Down
296 changes: 296 additions & 0 deletions docs/SCHEDULING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
# OpenCode Loop scheduling semantics

Loop is an **idle-safe continuation and scheduling layer**. It is not intended to stack model turns while OpenCode is already working.

The key rule is simple:

> A job may become **due** because of idle state, a timer, a watch trigger, or `/loop-now`, but a prompt/command/shell action is dispatched only when the session is safe to use.

That means a timer expiring does **not** imply a second assistant turn is injected on top of a busy turn. Due work waits for idle.

## The four useful forms

### 1. Continue forever whenever the assistant stops

```text
/loop continue
```

or:

```text
/loop devam et
```

or explicitly:

```text
/loop idle continue
```

This is the normal Claude Code-style continuation loop.

Semantics:

- schedule mode: `idle`
- first run: next safe idle boundary
- later runs: every later safe idle boundary
- run limit: unlimited unless `--max-runs` / `--max-runtime` is supplied
- overlap: disabled by default

If the assistant finishes a turn, Loop sends the prompt again. If that next turn finishes, Loop sends it again, and so on until the job is paused/stopped, a configured limit is reached, or the host/session goes away.

Short continuation prompts such as `continue`, `keep going`, and `devam et` receive extra project-continuation guidance. The agent is told to treat the turn as continuation of the existing conversation/repository, inspect relevant files/TODO/progress/git state as needed, choose the next unfinished step, and avoid redoing completed work.

For a real project, a stronger version is:

```text
/loop --safe --ask-never --progress-file progress.md devam et
```

Initialize the progress file first if needed:

```text
/loop-init
```

### 2. Repeat every N minutes

```text
/loop every 5m continue the project
```

Semantics:

- schedule mode: `interval`
- first run: after 5 minutes
- later runs: every 5 minutes according to the Loop due clock
- if due while OpenCode is busy: wait until idle, then run once
- missed busy intervals are not intentionally stacked into multiple prompts

Use this when you really mean a recurring timer.

You can still use the older compact form:

```text
/loop 5m continue the project
```

For backward compatibility, the compact form starts on the next safe idle boundary and then uses the 5-minute interval. To delay the first legacy-form run, use:

```text
/loop 5m --no-now continue the project
```

The explicit `every 5m` form is recommended because its first-run behavior is obvious.

### 3. Do something once after a delay

```text
/loop after 5m continue once
```

Alias:

```text
/loop in 5m continue once
```

Semantics:

- schedule mode: `once`
- first/only due time: 5 minutes after creation
- dispatch: only when the session is idle
- run limit: one

If the five minutes expire while a model/tool/subtask is still running, the one-shot job waits. It does not interrupt the active turn.

The legacy equivalent is roughly:

```text
/loop 5m --no-now --max-runs 1 continue once
```

### 4. Run when a watched path changes

```text
/loop --watch progress.md inspect the new progress and continue
```

Watch jobs remain dormant until their watch condition is triggered, then use the same idle-safe dispatch path.

## Schedule truth table

| Command | Mode | First dispatch | Repeats? |
|---|---|---|---|
| `/loop continue` | idle | next safe idle | yes, every idle |
| `/loop idle continue` | idle | next safe idle | yes, every idle |
| `/loop every 5m continue` | interval | after 5m, then first safe idle | yes |
| `/loop after 5m continue` | once | after 5m, then first safe idle | no |
| `/loop in 5m continue` | once | after 5m, then first safe idle | no |
| `/loop 5m continue` | legacy interval | next safe idle | yes |
| `/loop 5m --no-now continue` | legacy interval | after 5m, then first safe idle | yes |
| `/loop 0s continue` | legacy idle | next safe idle | yes, every idle |

## What “idle-safe” means

Before dispatching a Loop-owned turn, the runtime checks:

1. no Loop run is already being dispatched for the session;
2. OpenCode is not reporting a live running turn that still has unfinished assistant output;
3. no active tool call is known for the session;
4. no busy descendant/subtask session is known;
5. `noOverlap` / active-run guards allow another turn;
6. the job is still enabled, unpaused, and within its configured limits.

If any of those checks fail, the job remains due and Loop retries later.

## Stale `busy` recovery

Some OpenCode TUI builds can leave `session.status` at `busy` or `retry` after a plugin command acknowledgement even though the assistant message is already completed. This can otherwise produce the classic symptom:

```text
Loop added
runCount = 0
lastRunAt = 0
```

The runtime now cross-checks stale live status with the chronological session tail **before the first Loop run too**.

Recovery is conservative:

- latest assistant tail has a real completion timestamp -> stale busy may be recovered to idle;
- latest assistant tail is unfinished -> remain busy;
- latest message is user/non-assistant -> remain busy;
- active tool or busy child session -> remain busy;
- unknown completion -> remain busy.

So Loop can recover a stale host status without treating a genuinely running turn as finished.

A recovery is written to `loop.log` as:

```text
status-message-idle-recovery
```

## Busy deferral logging

When a due job cannot run because the session is still busy, Loop now emits throttled diagnostics instead of silently leaving only the original `add` line.

Typical event:

```text
deferred reason=session-busy source=due
```

The log is throttled so a 5-second busy retry does not flood `loop.log`.

Inspect recent events with:

```text
/loop-logs
```

## `/loop-status`

Status now separates the schedule definition from its current state.

Examples:

```text
schedule=every idle | state=waiting for idle
schedule=every 5m, first after 5m | state=due in 3m
schedule=once after 5m | state=due; waiting for idle
```

This distinction is important: **due** is a timing fact; **waiting for idle** is an admission/safety fact.

## `/loop-doctor` and session-bound jobs

Normal plugin Loop jobs are session-bound and persist under:

```text
.opencode/opencode-loop/<session-id>.json
```

Starting a new OpenCode session does not move old Loop jobs into the new session.

`/loop-doctor` reports:

- current session ID;
- number of current-session jobs;
- whether a dedicated `/goal` state is detected for the same session;
- other persisted session state files that still contain enabled jobs;
- how many of those jobs have never run.

This makes an old enabled job visible instead of looking like the current session mysteriously lost it.

For work that must keep running after the TUI/session closes, use `opencode-loopd`; the normal plugin scheduler is intentionally session-bound.

## Dedicated `/goal` coexistence

Loop and OpenCode Goals can be installed together, but they should not both own autonomous continuation of the same session.

If dedicated `/goal` is currently `active`, Loop blocks a new prompt-producing `/loop` job in that same session by default:

```text
Prompt loop not added: dedicated /goal already owns continuation in this session.
```

Recommended choices:

- let `/goal` own autonomous continuation and use Loop only in another session;
- pause/finish the Goal before starting a prompt Loop;
- use scheduled shell/command work only when you understand possible file/verification races.

An explicit escape hatch exists for advanced use:

```text
/loop --allow-goal-overlap continue
```

Use that only when duplicate autonomous turn ownership is intentional.

## Recommended “understand this project and keep going” workflow

For an unfamiliar repository:

```text
/loop-init
```

Then:

```text
/loop --safe --ask-never --progress-file progress.md Understand the existing project architecture and current state first. Inspect the relevant source, tests, docs, TODOs, git status, and recent work. Record the useful state in progress.md, choose the next unfinished safe improvement, implement it, verify it, update progress.md, and continue from there on later idle turns.
```

After the project state is established, a short continuation loop is enough:

```text
/loop --safe --ask-never --progress-file progress.md devam et
```

Because `devam et` is recognized as continuation shorthand, later turns are instructed to resume the existing project rather than start a new interpretation from scratch.

## Stopping and limits

Idle loops are intentionally unlimited by default. Bound them when needed:

```text
/loop --max-runs 20 continue
/loop --max-runtime 6h continue
/loop every 5m --max-failures 3 continue
```

Control commands:

```text
/loop-pause
/loop-resume
/loop-stop
/loop-clear
/loop-now
```

`/loop-now` marks a job due immediately but still uses the idle-safe scheduler; it does not re-enter the model from inside the control-command hook.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
"build:plugin:npm": "npm run build:plugin",
"prepack": "node --check src/index.js",
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
"test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
"test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
"install:global": "node scripts/install-with-goals.mjs",
"pack:zip": "node scripts/make-zip.mjs"
Expand Down
2 changes: 1 addition & 1 deletion scripts/comprehensive-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async function testParserAndPresets() {
assert.match(preset.action, /Test command hint: npm run ci/)

const toastCount = h.records.toasts.length
await h.command("loop", "nonsense")
await h.command("loop", "every nope continue")
await h.command("loop", "5m")
assert.equal(h.records.toasts.length, toastCount + 2)
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
Expand Down
Loading