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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ This project is independently implemented for OpenCode. Product names used elsew
| Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
| Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
| OpenCode 2 | Supported and verified against OpenCode 2.0.16 (plugin load, `/goal` command, native agents); hook wiring pinned by `test/v2-bridge.test.js`. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) |
| OpenCode 2 | Verified against 2.0.16: plugin load, `/goal`, native agents, Plan holds, restricted independent completion auditing, foreign-verifier rejection, and safe reload recovery. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) |

See the [compatibility policy](docs/compatibility.md) for the supported public
surface and versioning expectations.
Expand Down Expand Up @@ -96,6 +96,10 @@ Restart OpenCode after editing the config. The options form `["opencode-goal-plu

### Upgrading

For **0.11.0**, use the config for your OpenCode version above and restart OpenCode. When moving from OpenCode 1 to 2, remove the legacy `command.goal` entry so the plugin owns `/goal`. Upgrade every process sharing the same goal state before resuming work; recovered active goals load paused and require `/goal resume`.

The release adds OpenCode 2 support and fixes Plan activation, stale completion approvals after objective edits, invalid completion evidence, and persistence on filesystems with capped timestamps or unsupported hard links. See the [changelog](CHANGELOG.md) for the full changes.

**Pin the version.** OpenCode resolves an unpinned `"opencode-goal-plugin"` entry to `@latest` exactly once, installs it under its package cache (`~/.cache/opencode/packages/opencode-goal-plugin@latest/` by default; `opencode debug paths` prints the cache root), and never re-resolves `latest` while that directory exists. An unpinned entry therefore stays on whichever version was first installed, indefinitely, and new releases on npm are never picked up — a bug fixed months ago can still be running locally.

To upgrade, bump the pin (for example to `opencode-goal-plugin@0.11.0`) and restart OpenCode; every pinned version gets its own cache directory. If you kept an unpinned entry, delete the `opencode-goal-plugin*` directories under the cache `packages/` folder and restart. `npx opencode-goal-plugin` runs the bundled verification script, which warns when the cached copy lags the package.
Expand Down Expand Up @@ -300,9 +304,8 @@ Recovered active goals are loaded in a **paused** state with a recovery note, so

Only one OpenCode process may own a given session shard at a time. If the same session is opened in a second process, that process enters **passive goal mode** instead of failing the whole session: ordinary chat and unrelated tools continue to work, but `/goal` commands and goal tools report that another process owns the workflow. Canonical goal tools return the stable envelope code `error: "session_owned_elsewhere"`. The passive process does not read, mutate, persist, or auto-continue that session's goal state. After the owner exits, retry an explicit goal command or goal tool; the process will acquire the shard and load any recovered active goal paused. To work concurrently without waiting, create a new session with `opencode --continue --fork` (or `opencode --session <id> --fork`).

The owning side of that guarantee is also self-healing within one process. A long-running host (for example `opencode serve`) can reload a location's plugin instance — a file-watcher event under `.opencode`, including this plugin's own state writes, can trigger that — without disposing the instance it replaces. That orphaned instance's claim would otherwise stay live forever (its process never exits), permanently blocking its own successor. Each instance instead refreshes its claim every time it actually handles that session's events; an instance that stops receiving them goes quiet and its claim goes stale after several minutes of inactivity, at which point a same-host successor reclaims it automatically — no manual recovery needed. This staleness path never applies across hosts: a different machine's claim is never reclaimed on a timer, since network partitions and clock skew make "stopped heartbeating" indistinguishable from "still alive but slow to reach us".
On OpenCode 2, same-process reloads for a location wait for the previous plugin instance to finish disposal before starting its replacement. This releases the old instance's claims and lets the replacement recover active work paused. A quiet live owner remains authoritative; claims are never reclaimed merely because time has passed.

Lease ownership uses immutable per-process claim files so a delayed stale-lock cleanup or duplicate release cannot delete a newer owner's lease. The plugin publishes a complete regular-file compatibility guard atomically at `<shard>/state.json.lock`, then elects the current owner from claims in the sibling `<shard>/state.json.lock.claims-v2/` directory. That no-replace publication makes startup safe against older releases: either the older lock directory wins and the current plugin stays passive, or the guard file wins and the older release cannot reclaim it. Automatic ownership handoff requires the current release. Legacy, incomplete, tampered, or unsupported lease layouts fail closed instead of being rewritten online; filesystems must support regular-file hard links and preserve the guard's future timestamp. After confirming that every process using the session is closed and upgraded, either fork or remove only the affected shard's adjacent `.lock` file or legacy directory **and** `.lock.claims-v2` directory; keep its state and ledger.
Known lease capability failures also enter passive goal mode: unsupported hard links (including `ENOSYS`), unsafe lease paths, or a filesystem that cannot preserve the guard timestamp. Goal tools return `error: "persistence_unavailable"` with repair guidance, rather than reporting another owner. Ordinary chat continues, but goal state is not loaded, changed, or auto-continued while passive. Close processes using the session before repairing the paths or moving persistence to a compatible filesystem, then explicitly retry a goal command or tool. Unexpected I/O errors still surface.

Lease ownership uses immutable per-process claim files so a delayed stale-lock cleanup or duplicate release cannot delete a newer owner's lease. The plugin publishes a complete regular-file compatibility guard atomically at `<shard>/state.json.lock`, then elects the current owner from claims in the sibling `<shard>/state.json.lock.claims-v2/` directory. That no-replace publication makes startup safe against older releases: either the older lock directory wins and the current plugin stays passive, or the guard file wins and the older release cannot reclaim it. Automatic ownership handoff requires the current release. Legacy, incomplete, tampered, or unsupported lease layouts fail closed instead of being rewritten online; filesystems must support regular-file hard links and preserve the guard's future timestamp. New guards use January 1, 2038, within signed 32-bit timestamp limits; existing 2100 guards are accepted without rewriting. Guards within 30 seconds of their timestamp or already expired fail closed. Upgrade all processes sharing a session: earlier v2 releases may treat a new 2038 guard as incomplete and stay passive. After confirming that every process using the session is closed and upgraded, either fork or remove only the affected shard's adjacent `.lock` file or legacy directory **and** `.lock.claims-v2` directory; keep its state and ledger.
Expand Down Expand Up @@ -521,6 +524,8 @@ For `/goal status`, `/goal history`, `/goal list`, `/goal pause`, and `/goal cle

The plugin still registers `experimental.chat.system.transform` as defense in depth for hosts that invoke it. Real OpenCode 1.17.15 and 1.18.10 do not call that hook, so the command-control protections above are deliberately self-contained. Other OpenCode plugin hooks may change between versions.

On OpenCode 2.0.16, independent completion auditing leaves its verifier child sessions retained: the host's plugin API does not expose session removal. The verifier remains restricted to read/glob/grep tools, and the parent completes only after its verdict. See [OpenCode 2 compatibility](docs/compatibility.md#opencode-2) for the tested host behavior.

Distinct OpenCode sessions may own shards under the same `stateFilePath` concurrently. A second process using the same session shard remains usable in passive goal mode, but goal commands and tools are denied until it can acquire that shard. The passive process never falls back to an unpersisted copy of the same goal workflow, which avoids divergent state and last-writer-wins data loss. Use the owner, wait and retry an explicit goal control after it exits, or fork to a new session. A no-replace compatibility guard prevents an older release and the current release from both acquiring during startup; current immutable claims protect takeover and release among upgraded processes. Older processes cannot take over a guarded shard, so all processes participating in automatic same-session handoff must run the current release.

## Diagnostics and recovery
Expand Down
2 changes: 1 addition & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ lease, budgets, tool surface, ledger) is shared code rather than a fork.

| Surface | Result |
| --- | --- |
| Package exports | Installed-tarball contracts validate the dual V1/V2 entrypoint; registry installation requires publishing this version |
| Package exports | Installed-tarball contracts validate the dual V1/V2 entrypoint; the exact 0.11.0 tarball passed independent macOS host acceptance |
| Plugin load through a local `.opencode/plugins/<file>.js` entry | `state: active` |
| Slash command registered through `ctx.command.transform` | `goal` listed by `GET /api/command` with the plugin's description |
| Native agents registered through `ctx.agent.transform` | `goal` (primary) and `goal-verify` (subagent, hidden) present in `GET /api/agent` with their system prompts and translated permission rules |
Expand Down
Loading