Durable goal supervision for OpenCode — verified objectives, bounded retries, scheduling, and a goal dashboard. OpenCode runs the sessions; loopd supervises the goals.
Run long-running tasks as autonomous child sessions (subagents) while the main chat stays interactive. Create a goal with
/goal, watch it loop in the background, and monitor everything from a keyboard-driven TUI — without blocking the parent conversation.
Modal dashboard (/loop / <leader>o): zero chat pollution — keys are trapped inside the dialog, NORMAL vs INSERT modes, vivid per-status coloring.
- Background agents, not blocked chats — an OpenCode plugin that spawns a dedicated worker (child) session per goal. The parent stays free to keep chatting.
- Goal-driven continuation — native v2 execution outcomes advance goal policy with accumulated context. Transcript-based idle recovery remains a legacy compatibility path.
- Parent ↔ child visibility —
list/inspect/read_transcript/send_inputgive the parent full observability. Bidirectional inbox lets you steer mid-run. - Safe by default — per-goal artifact isolation (
.opencode/loopd/goals/<id>/),maxTurns/maxFailures/maxNoProgress, force-finish → semanticcomplete_goalsummary → parent notification via wake-up injection. - Scheduled intervals —
scheduleEveryMs/scheduleMaxRunsauto-requeues the same goal every N ms (e.g.,10smonitor,1hreport) without manual/goalspam —5spoll,skip-if-running,workspaceWriteserialization, inboxScheduled tick N/M. - Modal TUI dashboard —
<leader>oor/loopopens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders. - Command sessions, not blocked shells — run installs, builds, dev servers, and REPLs as background OS processes with a fullscreen terminal, owner exit notifications, line-level
filter/untilwatches, and timeouts — instead of freezing the agent turn inbash. - Switch identity mid-run —
loopd_list_modelsdiscovers models and agents;switch_goal_identitychanges a goal's model, agent, or both on the same worker/session. Combined changes persist atomically and roll back on partial failure. Optional orderedfallbackModelsswitch automatically on 429s. Dashboard::model/:agentand:models/:agents. - Manual mode for interactive work —
interactive: true(or:interactive) stops the engine from starting turns on its own; every turn comes from your:send/nudge. For tasks where the worker waits for input.
Keywords: opencode opencode-plugin background-agent autonomous subagent loop goal tui codex claude-code worker
On supported v2 hosts, OpenCode owns execution, queued message delivery and session lifecycle. Loopd owns objectives, acceptance checks, budgets, scheduling and cross-goal coordination. See v2-first supervision for the audited contracts, restart reconciliation and legacy boundaries.
Exact-file editing contracts and their execution boundaries are documented in
File-scope ownership. Use write_scope: [] to
explore before claiming files; omitted scope keeps legacy exclusivity.
Every promise loopd makes about a running goal — checked completion, one owner
notification, queued steering, durable pause, final clear — is listed in the
Lifecycle contract and asserted against a real
opencode serve process before each release. That gate is what
bun run smoke:v2-lifecycle runs; it is not a mocked unit test.
opencode-loopd is an OpenCode plugin (server + TUI) that adds background goals to OpenCode. Each goal owns a worker (child) subagent that loops autonomously; the loop engine drives continuations; the schedule worker requeues the same goal on an interval (scheduleEveryMs); the dashboard and owner tools keep the parent in control. Think Codex goals or Claude Code loop, but native to OpenCode's session model — with scheduled intervals for repetitive dialogues.
Add the package to both configs.
~/.config/opencode/opencode.jsonc — server engine:
~/.config/opencode/tui.json — dashboard (modal TUI):
{
"plugin": ["@bojackduy/opencode-loopd"]
}Then restart OpenCode. Verify with /loop (palette → Loop Dashboard) or <leader>o.
For a local checkout:
// opencode.jsonc
{ "plugin": ["./path/to/opencode-loopd"] }
// tui.json
{ "plugin": ["./path/to/opencode-loopd"] }npx -y @bojackduy/opencode-loopd@latestRegisters the plugin, installs the /goal slash command and the loopd skill into ~/.config/opencode/. Run again to update. Then restart OpenCode.
Uninstall:
npx -y @bojackduy/opencode-loopd@latest --uninstallOr via global npm:
npm install -g @bojackduy/opencode-loopd@latest
opencode-loopd # install
opencode-loopd --uninstall # removeThen restart OpenCode.
Add to ~/.config/opencode/opencode.jsonc:
{
"plugin": ["@bojackduy/opencode-loopd"]
}Or for local checkout:
{
"plugin": ["/Users/you/Code/opencode-loopd"]
}For safe code-editing defaults, configure the server plugin with an agent and verification commands:
{
"plugin": [[
"@bojackduy/opencode-loopd",
{
"defaultAgent": "smart-agent",
"defaultChecks": ["bun test", "bun run typecheck"]
}
]]
}opencode-loopd exposes:
opencode-loopd/server→ engine pluginopencode-loopd/tui→ dashboard plugin
From any session, tell the agent:
/goal fetch the latest AI news and save 10 items to ai-news.md
Or be explicit:
Create a background goal to find all .ts files in src/, count lines, and write the summary to line-counts.md — verify with grep -c.
The agent will clarify (what/where/how to verify) and then call loopd_create_goal. The worker starts immediately in a hidden child session.
Press <leader>o or open the command palette → "Loop Dashboard" (also /loop).
Dashboard (NORMAL / INSERT :) — shared Goals/Commands tabs:
Tab— toggle Goals / Commands ·hGoals ·lCommands (/commandsopens on Commands)j/k— move selection ·g/G— top / bottom (active tab only)o— goals: open child session (native OpenCode view) · commands: close popup, open fullscreen terminal page (routeopencode.loopd.terminal)p/r/R/x— pause / resume / retry / clear selected goal (Goals tab only)c— toggle finished on both tabs (completed goals · exited/terminated/missing commands):interactive— toggle manual mode (engine never starts turns) ·:model <provider/model>— switch goal's model ·:models— provider/model inventory- Commands tab:
Xforce-kill ·Rrestart ·xremove finished ·:new <command> [args...]launch ·:interrupt·:terminate·:remove· bare text + Enter writes stdin (owner-scoped) :— insert mode →:send <message>to steer,:force/:blockto finish manually?— toggle help — help stays open while you typeCtrl+N— back to NORMALq— close
The dashboard traps all keys — even when open via the palette, the chat input cursor won't blink underneath.
Fullscreen terminal page: raw typing goes straight to the PTY, Ctrl+C
interrupts (never closes OpenCode), Ctrl+] detaches back to your session
(the command keeps running — termination is explicit-only), paste is instant.
The parent never needs the dashboard — it can ask:
list_background_goals() — status snapshot
inspect_background_goal() — full detail: objective, progress, blocker, runtime
read_goal_transcript() — last N worker messages (what it's doing)
send_goal_input(goalID, msg) — steer: "skip appendix PDFs"
pause_goal(goalID) / resume / clear
loopd_list_models() — providers/models, quota status (unknown where hosts can't report it)
switch_goal_identity({goal_id, model?, agent?}) — same goal, new identity (same worker/session; at least one field required)
loopd_command_start(...) — installs/builds/servers as background processes (never block the turn in bash)
The child sees:
get_goal— read objective, contract, progress, failuresreport_goal_progress— after durable writescomplete_goal— only when checks pass, with evidenceblock_goal— real blocker needing userquestion(OpenCode builtin) — clarification → TUI blocker tab
Completion is semantic: the child writes the summary; the plugin forwards it to the parent via wake-up injection. If limits are hit, the engine first force-asks the child to complete_goal; only if ignored does it auto-block with a generic message.
┌─────────────────────────┐
│ Main session (parent) │
│ owner tools │
│ inspect / steer / pause│
└────────────┬────────────┘
│ inbox → child
│ wake-up ← engine
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌─────────┐
│ Goal A │ │ Goal B │ │ Goal C │
│ active │ │ blocked │ │ paused │
└────┬────┘ └────┬─────┘ └────┬────┘
│ │ │
└─────────────────────┼───────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
Loopd engine ──────────► Child worker (subagent)
(plugin server) steering get_goal / report / complete
│ lease / retry / polling │ block / question
│ force-finish / notify ▼
│ .opencode/loopd/goals/<id>/
│ progress.md / artifacts
└─────────────────────────────────┘
- Engine-driven loop (like Codex):
session.idle→ lease + retry + polling → re-prompt child. Not a parent-driven re-prompt machine. - Scheduled requeue (1.7):
createScheduleWorker5sscanscompletegoals withscheduleEveryMs/maxRuns;complete→activeresurrect withScheduled tick N/Minbox,skip-if-runningandworkspaceWritegate. - Accumulated context: each continuation includes progress history, transcript tail, inbox messages, and artifact dir (unless objective names another dir).
- Parent wake-up: when the child calls
complete_goal/block_goal,tool.execute.afterinjects the child's semantic summary into the owner's session — you see "Loop goal … completed: …" without polling.
Every goal writes under its own directory (.opencode/loopd/goals/<goal-id>/); progressFile defaults there. The dashboard and transcript never pollute the project root. If your objective says save to ./reports/, that wins.
# Parent: /goal
goal: "Generate 5 files file1..5.md under artifacts/, 2 lines each — verify with glob."
maxTurns: 1
# Turn 1: child writes file1..3, reports progress
# → engine hits maxTurns, injects FINAL REPORT REQUIRED
# Turn 2 (forced): child wraps up, calls complete_goal(summary="created 5 files…", evidence="glob confirmed…")
# → engine forwards to parent: "Loop goal … completed: created 5 files…"
# → Artifacts: .opencode/loopd/goals/<id>/file*.md
See assets/demo.png above for the dashboard at rest (0 active, 7 done) with vivid status colors, running spinner, and INSERT badge.
Goals accept:
| Option | Default | Description |
|---|---|---|
maxTurns |
50 |
Max continuation turns before force-finish |
maxFailures |
3 |
Consecutive failures before block |
maxNoProgress |
3 |
Turns without progress before force-finish |
timeoutMs |
300000 |
Per-turn lease (5 min) |
compactEvery |
— | Compact child every N turns |
scheduleEveryMs |
— | Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction without manual re-prompt |
scheduleMaxRuns |
— | Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs |
checks |
[] |
Shell commands that must pass for complete_goal |
checkCwd |
artifact directory | Directory where completion checks run; workspace writes default to project root |
workspaceWrite |
true |
Marks shared-workspace mutation; only one active workspace writer is allowed. Set false explicitly for artifact-only/read-only work |
agent |
defaultAgent |
Worker agent; goal creation fails if neither is configured nor supplied |
interactive |
false |
Manual mode: engine never starts turns (:send/nudge only). For workers that wait for input |
fallbackModels |
[] |
Ordered provider/model alternatives tried on quota/rate-limit errors only. Empty = no automatic switch |
progressFile |
<artifactDir>/progress.md |
Transaction state file |
artifactDir |
goals/<id>/ |
Auto — override only if objective names another dir |
Artifacts, logs, and state live under .opencode/loopd/ (project-local). Locks live in /tmp/loopd-locks/<project-hash>/ to avoid snapshot noise.
Workspace-writing goals must have deterministic checks and are serialized to
prevent concurrent agents or git stash operations from overwriting each
other. Artifact-only research goals may run concurrently by setting
workspaceWrite: false explicitly.
One /goal can now loop on an interval — the engine requeues the same goal after each complete:
loopd_create_goal({
name: "hourly-report",
objective: "Append date -u to tick.txt each run",
agent: "smart-agent",
checks: ["test -f tick.txt"],
scheduleEveryMs: 3600000, // 1h, min 1000
scheduleMaxRuns: 24, // total incl initial, omit for unlimited
workspaceWrite: false
})
- Poll:
5screateScheduleWorkerscanscompletescheduled goals;skip-if-running(lease/phase/workspaceWritegate). - Resurrect:
complete → activephase=idle, injects inboxScheduled tick N/M — re-execute objective, thencontinueTurn. - State:
scheduleRunCount/nextRunAt/lastScheduleAt(v6state.json),schedule.tickevents,schedule.resurrectedserver log. - Live proof:
sched-live-verify09cb837c10smaxRuns 3→tick.txt3lines02:00:5602:03:4302:05:47withrunCount 3.
Without schedule you’d send /goal 3× manually; with schedule one creation covers monitor deploy, periodic tests, keep going until done.
When a provider hits quota or rate limits mid-run:
loopd_list_models() — what's connected, quota status (or honestly unknown)
switch_goal_identity({goal_id, model:"other-provider/good-model"}) — same goal, same worker/session, transcript kept
- Busy workers defer the switch to the next turn (
→ pending … applies next turn); idle ones switch immediately. On v1 the new model takes effect from the next prompt (no mid-stream session switch API); v2 switches the live session where supported, else reportsunsupportedwithout touching anything. fallbackModels: ["b/model", "c/model"]at creation tries alternatives automatically — quota/429 errors only, each once, never looping. Interactive goals record the fallback but never auto-wake; you steer the next turn.- Dashboard:
:model <provider/model>and:modelsdo the same for humans.
- Press
:then type bare text → sent assend_goal_inputto the selected goal. - Bare
:force my summary --evidence "glob ok"bypasses verification when the child is stuck. ojumps to the native OpenCode child session — full transcript, diffs, tool calls.
bun run typecheck && bun test # unit + integration
bun run build:server && bun run smoke:v2-lifecycle # real OpenCode v2 hostsmoke:v2-lifecycle boots a throwaway opencode serve with private
XDG_*/database state and a local fixture model, then asserts the lifecycle
guarantees end to end. CI runs it before npm publish, and uploads its private
artifacts on failure. See Lifecycle contract for
what it covers and what it deliberately does not.
OpenCode plugin for background agents, autonomous subagents, loop, goal, TUI dashboard, Codex-style continuation, Claude Code loop alternative, opencode-tui, worker sessions. Pairs well with opencode-telescope (search) and lazyjira/lazyconfluence (ATUI) from the same author.
AGPL-3.0-or-later — see LICENSE.

{ "plugin": ["@bojackduy/opencode-loopd"] }