opencode-goal-plugin 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 — 2026-09-06
6
+
7
+ - Fix the plan-mode guard failing open on the first command of a session. OpenCode runs `command.execute.before` before any chat hook for the turn and its `Session` record carries no `agent` field, so the session-record fallback introduced in 0.9.0 could never learn the agent for a `/goal <objective>` sent as a fresh session's first turn: the goal started live and the routed text told the model to begin work. The routed turn itself carries the agent, so `chat.message` now re-evaluates the planning-only restriction for the goal that command created and, when the agent is restricted, holds the goal (`plan agent active`, budget preserved), rewrites the turn as a read-only control turn, and blocks tools for it. Reproduced, and verified fixed, against live OpenCode 1.18.25 and 1.18.29 over the HTTP `command` API with `agent: "plan"` and a `goal` command that does not pin an agent; the case where an earlier turn had already reported the agent was unaffected. Note that OpenCode runs a command under its configured `agent` when one is set (`cmd.agent ?? input.agent`), so with the README's `"agent": "build"` the `/goal` turn executes as `build` and a hold can only come from a previously reported planning-only agent; the README's plan-mode section now spells this out. Pinned by a new mutation-contract entry.
8
+ - Fix the `sessionTitleStatus` indicator leaving a stale `▶` running line on the session after the goal completed. Completion archives the goal, and the title sync returned early with no live goal, so the last running render stayed until `/goal clear`. A completed goal now renders as `✅ <objective> · N turns · <elapsed> · <tokens>`; `/goal clear` still restores the captured original title. Reproduced against live OpenCode 1.18.25 and 1.18.29.
9
+ - Treat a fenced ```` ```span``` ```` in `/goal` arguments as literal objective text: double-dash tokens inside it are no longer parsed as goal flags (previously `/goal run pytest --maxfail=1` rejected the command with `Unsupported flag: --maxfail`), a fence is never consumed as a flag value, and the backticks are stripped from the stored objective. Ported from [@NiklasTR](https://github.com/NiklasTR)'s fork.
10
+ - Add a cost cap: the `maxCostUsd` plugin option and `--max-cost` per-goal flag pause a goal, with the usual final wrap-up prompt, once the cumulative API cost OpenCode reports for the session reaches the cap (`stopReason: max cost reached ($X.XX)`). `/goal status` shows `Cost budget: $spent/$cap`, the continuation `<progress_budget>` carries `cost_remaining_usd`, the near-limit warning fires within 10% of the cap, and the `goal_set`/`set_goal` tools accept `maxCostUsd`. An unknown provider cost never trips the cap and a single response may overshoot it; `/goal resume` opens a fresh window. Idea and first implementation by [@NiklasTR](https://github.com/NiklasTR).
11
+ - Add `agentGoalAuthority`. The default `"full"` is unchanged; `"status"` keeps the agent-facing tools to reporting: `goal_set`/`set_goal` refuse to replace an active goal (returned as an `agent_authority` failure envelope on the canonical tool), `update_goal` refuses objective changes, and `clear_goal` refuses to clear, so a model can no longer rewrite the objective the user gave it. Completing, blocking, pausing, resuming, and creating a goal when none is live remain allowed. Prompted by [@felores](https://github.com/felores)'s fork, which removed those abilities outright.
12
+ - Make the `simultaneous stale observers` lease test deterministic: attach its settle handler before the barriers so an early loser's rejection is never reported as unhandled, and never wait at a barrier for an acquirer that already settled. This was the known-flaky Windows CI test; it also failed locally under load.
13
+ - Update the bundled `zod` dependency from 4.4.3 to 4.5.4 and the CodeQL action to v4.37.9 (supersedes dependabot #60 and #69).
14
+ - Rewrite the install docs around how OpenCode actually loads plugins. `npm install` was never needed; the config entry is what installs the package. More importantly, OpenCode resolves an unpinned `"opencode-goal-plugin"` to `@latest` once, caches it under `~/.cache/opencode/packages/opencode-goal-plugin@latest/`, and never re-resolves while that directory exists, so users who followed the old instructions are frozen on whichever version they first installed. The README now pins the version, documents the upgrade path (bump the pin, or delete the cached directory), mentions `opencode plugin <name>@<version>`, and notes that a `file://` checkout needs its `node_modules`. `npx opencode-goal-plugin` (the bundled verify script) warns when OpenCode's unpinned cache lags the package. Also documents `ledgerFilePath` and adds an OpenCode 1.18.29 compatibility row from a deterministic-provider canary.
15
+
16
+ ## 0.9.0 — 2026-08-29
17
+
18
+ - Add plan-mode safety: a goal set while a planning-only agent is active is recorded but held instead of starting, with stop reason `plan agent active`, its budget preserved, and a read-only control turn that tells the model not to begin work. Auto-continue already paused when the session switched to Plan; this closes the creation path, where the goal previously started and ran the loop before any idle occurred. The active agent is read from the host's execution context with a fallback to the session record, which matters because `command.execute.before` runs before `chat.message`/`chat.params` — without the fallback the restriction failed open on the first command in a session. Configurable via `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`); the default-on behavior is pinned by the mutation contract. Note the restriction stops the goal loop, not the single routed command turn, which OpenCode does not fully intercept.
19
+ - Add an opt-in session-title status indicator (`sessionTitleStatus`), mirroring live goal status — state icon, objective, turns, elapsed, and tokens — into the OpenCode session title so unattended runs show a continuous heartbeat. The original title is captured before the first overwrite and restored by `/goal clear`; unchanged renders skip the API call, and update failures are logged at debug level without interrupting the goal loop. The indicator refreshes on commands and on idle/compaction/interruption events but never on the `message.updated` events that stream during a turn, keeping API round-trips out of the response path. Status lines written by a previous process are recognized as the plugin's own, so a restart followed by `/goal clear` cannot promote stale goal status to the session's permanent title.
20
+ - Verify both features against a live OpenCode 1.18.25 TUI, not only unit and contract tests: a goal set under Plan records `stopped: true` with zero auto-continues, and the status indicator renders in the session title.
21
+ - Document OpenCode 2 status explicitly: unsupported and untested, with the existing dual-shape session adapter noted and a concrete checklist of what a supported claim would require. Records that this plugin is server-only, so its configuration lives entirely in `opencode.json` on any OpenCode line.
22
+
5
23
  ## 0.8.2 — 2026-08-21
6
24
 
7
25
  - Harden the post-compaction goal continuation guard introduced in
package/README.md CHANGED
@@ -31,13 +31,14 @@ This project is independently implemented for OpenCode. Product names used elsew
31
31
  | Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
32
32
  | Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
33
33
  | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
34
+ | OpenCode 2 | Not supported and not yet tested; the peer/engine pin is `>=1.17.15 <2`. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) |
34
35
 
35
36
  See the [compatibility policy](docs/compatibility.md) for the supported public
36
37
  surface and versioning expectations.
37
38
 
38
39
  ### OpenCode version compatibility
39
40
 
40
- Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
41
+ Tested against real OpenCode 1.17.15 and 1.18.25 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
41
42
 
42
43
  | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Historical custom-command presentation (v0.6.6) |
43
44
  |---|---|---|---|---|---|
@@ -45,24 +46,24 @@ Tested against real OpenCode 1.17.15 processes with live provider credentials an
45
46
  | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
46
47
  | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
47
48
  | 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
49
+ | 1.18.25 | opencode (`nemotron-3.5-lightning-free`) | ✅ | ✅ Held correctly under the Plan agent (`stopped: true`, zero auto-continues) | ✅ Clean `[goal:evidence]` + `[goal:complete]` | ⚠️ Not displayed; command text routed to model |
50
+ | 1.18.29 | deterministic localhost OpenAI-compatible fixture (no live provider) | ✅ Control turn routed; all 11 tools in every request | ✅ Idle continuation → completion; blocker paused with its reason | ✅ `[goal:evidence]` + `[goal:complete]` archived | ⚠️ Not displayed; command text routed to model |
48
51
 
49
52
  `/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
50
53
 
51
54
  **Note:** The table records the v0.6.6 live-provider matrix. In that release, OpenCode 1.17.15 retained the original command-parts array, so assigning a new `output.parts` array did not replace the raw command argument sent to the model. The current implementation mutates that retained array in place, making the plugin-generated command result the prompt for the turn. OpenCode custom commands still run through the model rather than rendering hook output directly, so the visible response may summarize or paraphrase the result (see [Limitations](#limitations)). Re-test against the exact OpenCode build and provider/backend stack you rely on for unattended work, and see [`docs/providers.md`](docs/providers.md) for the full historical model matrix.
52
55
 
56
+ The 1.18.29 row comes from a deterministic-provider canary against a real `opencode serve` process, graded on persisted state. That canary also reproduced the two defects fixed in 0.10.0 (a first-command Plan goal not being held; a stale running title after completion) on 1.18.25 and 1.18.29 alike.
57
+
53
58
  Separately, the lifecycle-feedback implementation included in v0.7.0 passed a real OpenCode 1.18.11 host canary covering create, status, pause, resume, edit, and default lifecycle logging with a deterministic localhost provider. That canary validates host integration, not another live-provider compatibility row.
54
59
 
55
60
  ## Install
56
61
 
57
- ```sh
58
- npm install opencode-goal-plugin
59
- ```
60
-
61
- Add the plugin and command to your OpenCode config:
62
+ OpenCode installs npm plugins itself from your config, so there is nothing to `npm install`. Add the plugin **with a pinned version** and the `goal` command to `opencode.json` (the user config at `~/.config/opencode/opencode.json`, or a project-local `opencode.json`):
62
63
 
63
64
  ```json
64
65
  {
65
- "plugin": ["opencode-goal-plugin"],
66
+ "plugin": ["opencode-goal-plugin@0.10.0"],
66
67
  "command": {
67
68
  "goal": {
68
69
  "description": "Set a session-scoped goal and auto-continue until complete.",
@@ -73,6 +74,20 @@ Add the plugin and command to your OpenCode config:
73
74
  }
74
75
  ```
75
76
 
77
+ Or let the CLI add the plugin entry for you and then add the `command` block by hand:
78
+
79
+ ```sh
80
+ opencode plugin opencode-goal-plugin@0.10.0 --global
81
+ ```
82
+
83
+ Restart OpenCode after editing the config. The options form `["opencode-goal-plugin@0.10.0", { ... }]` (see [Options](#options)) pins the same way.
84
+
85
+ ### Upgrading
86
+
87
+ **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.
88
+
89
+ To upgrade, bump the pin (for example to `opencode-goal-plugin@0.10.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.
90
+
76
91
  ## Usage
77
92
 
78
93
  Set a goal:
@@ -84,7 +99,7 @@ Set a goal:
84
99
  Override limits for a single goal:
85
100
 
86
101
  ```
87
- /goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000
102
+ /goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000 --max-cost 5
88
103
  ```
89
104
 
90
105
  Add success criteria, constraints / non-goals, and a mode:
@@ -97,6 +112,14 @@ Add success criteria, constraints / non-goals, and a mode:
97
112
 
98
113
  Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, given a non-positive integer, or (for `--mode`) an unrecognized mode, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text.
99
114
 
115
+ To include literal command-line options in an objective, wrap them in a Markdown fenced code span. Double-dash tokens inside the fence are objective text and are not parsed as goal flags:
116
+
117
+ ````text
118
+ /goal run ```pytest --maxfail=1 --disable-warnings``` and fix every failure
119
+ ````
120
+
121
+ Multiline fences work as well. The backticks are removed from the stored objective, and a fence is never consumed as a flag's value.
122
+
100
123
  Check status:
101
124
 
102
125
  ```
@@ -229,6 +252,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
229
252
  | Auto-continue turns | 10 |
230
253
  | Max duration | 15 minutes |
231
254
  | Context tokens | 200,000 |
255
+ | API cost (USD) | off — set `maxCostUsd` or `--max-cost` |
232
256
  | Min delay between continues | 1.5 seconds |
233
257
  | No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) |
234
258
  | Budget wrap-up threshold | 80% of context token budget |
@@ -238,6 +262,8 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
238
262
 
239
263
  **Token budget.** The plugin tracks the session's context window size (`input + output + reasoning` tokens on the latest message). This matches the token count that OpenCode displays, so the numbers should be consistent. When the context window reaches the `--max-tokens` limit, the plugin sends a wrap-up prompt and stops. In high-context sessions (large codebases, long conversation history), the context can grow quickly — treat the budget as a safety brake.
240
264
 
265
+ **Cost budget.** `maxCostUsd` (or `--max-cost 5` per goal) pauses the goal once the cumulative cost OpenCode reports for the session's assistant messages reaches the cap, with the same wrap-up prompt as the other limits; `/goal status` shows `Cost budget: $spent/$cap` and the continuation prompt carries `cost_remaining_usd`. Enforcement depends on the provider reporting cost — an unknown cost never trips the cap — and one response may overshoot it. `/goal resume` opens a fresh budget window.
266
+
241
267
  **No-progress heuristic.** A low-output turn does not pause immediately anymore. The plugin pauses only after `noProgressTurnsBeforePause` consecutive *stalled* low-output turns — repeated turns with very little output and no meaningful change in the latest assistant checkpoint.
242
268
 
243
269
  **No-tool-call heuristic.** Complementing the no-progress check, the plugin also watches for continuation turns that produce no tool calls at all (a "talk only" turn). Repeated talk-only turns usually mean the assistant is chatting to itself rather than doing work, so after `noToolCallTurnsBeforePause` consecutive tool-free continuation turns the plugin pauses. A turn that uses any tool (or delegates a subtask) resets the counter.
@@ -326,6 +352,7 @@ Pass options when registering the plugin to change the defaults for all goals. T
326
352
 
327
353
  Additional plugin-level options:
328
354
 
355
+ - `maxCostUsd` — cumulative OpenCode-reported API cost, in US dollars, before a goal pauses (default `0`, disabled). See the cost budget note under [Safety limits](#safety-limits).
329
356
  - `maxRecentMessages` — how many recent session messages to scan when looking for the latest assistant turn before auto-continuing. Higher values make long, tool-heavy sessions less likely to lose the most recent assistant response.
330
357
  - `noProgressTurnsBeforePause` — grace window for low-output stalls. The plugin pauses only after this many consecutive stalled low-output turns rather than on the first one.
331
358
  - `noToolCallTurnsBeforePause` — grace window for tool-free continuation turns. The plugin pauses after this many consecutive continuation turns that produced no tool calls (anti self-chat loop). Default `2`; set the plugin option to `0` for legitimate tool-free writing/research workflows.
@@ -335,11 +362,13 @@ Additional plugin-level options:
335
362
  - `commandName` — the slash command the plugin owns (default `goal`). Set it to e.g. `objective` to drive the workflow with `/objective` instead of `/goal`; a leading slash is tolerated. Remember to register the matching command name in your OpenCode `command` config. User-facing hints (`/goal status`, `/goal resume`, …) follow the configured name.
336
363
  - `registerCommand` — whether the plugin installs its `command.execute.before` hook at all (default `true`). Set it to `false` if you only want the auto-continue/persistence behavior driven programmatically and don't want the plugin to own a slash command.
337
364
  - `registerTools` — whether the plugin registers the agent-facing goal tools (default `true`). Set to `false` to omit the programmatic tool surface entirely. See [Agent tools](#agent-tools).
365
+ - `agentGoalAuthority` — `"full"` (default) or `"status"`. In `"status"` mode the agent tools can report on a goal but cannot replace, edit, or clear one; see [Agent tools](#agent-tools).
338
366
  - `registerAgents` — whether the config hook adds native `goal` and `goal-verify` agents (default `true`). Existing agents with those names are preserved unchanged; the plugin never changes your default agent.
339
367
  - `goalAgentName` / `verifierAgentName` — customize the registered native agent names (defaults `goal` and `goal-verify`). The verifier is a hidden subagent with a default-deny tool policy; only `read`, `glob`, and `grep` are allowed.
340
368
  - `sdkShape` — OpenCode session-client argument shape: `legacy` (the default generated `PluginInput` client using `{ path, body, query }`) or `flat` (clients using `{ sessionID, ... }`). Read-only `messages`/`get` calls may probe the alternate shape after an argument/schema `TypeError`; mutating calls are never replayed, so set this option correctly for embedded clients.
341
369
  - `persistState` — whether to persist active goals and recent goal results to disk.
342
370
  - `stateFilePath` — root path for the persisted session-shard namespace. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default root is `<cwd>/.opencode/goals/state.json`; shards are written below `<stateFilePath>.sessions/` (see the persistence section above).
371
+ - `ledgerFilePath` — override where the lifecycle ledger is written. By default each session shard keeps its ledger next to its state file as `<state.json>.ledger.jsonl`.
343
372
  - `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling.
344
373
  - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
345
374
  - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
@@ -355,7 +384,7 @@ Registered tools:
355
384
  - `goal_status`, `goal_set`, `goal_pause`, `goal_resume`, `goal_block`, and `goal_complete` are the canonical narrow operations. They return compact versioned JSON envelopes so agents can branch reliably without parsing prose.
356
385
  - `get_goal`, `get_goal_history`, `set_goal`, `update_goal`, and `clear_goal` remain compatibility aliases with their existing text responses.
357
386
 
358
- `goal_set` and `set_goal` are explicitly constrained to user-requested goals. `goal_complete` accepts a structured claim: a required non-empty `summary`, plus optional criterion/evidence pairs, checks (`passed`, `failed`, or `not-run`), changed files, and known limitations. Failed checks and empty criterion evidence are rejected before archival; accepted claims are serialized deterministically for the configured completion auditor. The legacy `update_goal` tool retains its string `evidence` field for compatibility.
387
+ `goal_set` and `set_goal` are explicitly constrained to user-requested goals by their descriptions. To enforce that in code, set `agentGoalAuthority: "status"`: agents may then complete, block, pause, or resume a goal and create one when none is live, but `goal_set`/`set_goal` refuse to replace an active goal, `update_goal` refuses objective changes, and `clear_goal` refuses to clear — only you, through `/goal`, `/goal add`, `/goal edit`, and `/goal clear`, can change what the goal *is*. The default `"full"` keeps the previous behavior, where a tool call can replace or rewrite the objective. `goal_complete` accepts a structured claim: a required non-empty `summary`, plus optional criterion/evidence pairs, checks (`passed`, `failed`, or `not-run`), changed files, and known limitations. Failed checks and empty criterion evidence are rejected before archival; accepted claims are serialized deterministically for the configured completion auditor. The legacy `update_goal` tool retains its string `evidence` field for compatibility.
359
388
 
360
389
  These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered sequence auto-promotes the next.
361
390
 
@@ -401,6 +430,64 @@ await GoalPlugin(
401
430
 
402
431
  `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied.
403
432
 
433
+ ## Status indicator
434
+
435
+ Unattended runs are easier to trust when you can see the goal is still alive. Set `sessionTitleStatus: true` and the plugin mirrors live goal status into the OpenCode session title, which the TUI renders persistently:
436
+
437
+ ```
438
+ ▶ ship the release · 3/10 · 2m · 45k/200k
439
+ ```
440
+
441
+ Status icon, objective, auto-continues used / limit, elapsed time, and context tokens / budget. The icon distinguishes running (`▶`), paused (`⏸`), and blocked (`⛔`) — blocked outranks paused because it needs you, not just a resume. A paused goal freezes its elapsed clock rather than running on. When the goal completes, the title switches to `✅ ship the release · 3 turns · 2m · 45k` so a finished run is never mistaken for a running one; `/goal clear` restores your original title.
442
+
443
+ ```json
444
+ {
445
+ "plugin": [
446
+ ["opencode-goal-plugin", { "sessionTitleStatus": true }]
447
+ ]
448
+ }
449
+ ```
450
+
451
+ The option is **off by default** because it overwrites a user-visible field. When enabled, the session's original title is captured before the first overwrite and restored by `/goal clear`. A render identical to the last one skips the API call, so `/goal status` and other read-only commands cost nothing. Title updates are cosmetic: a failure is logged at debug level and never interrupts the goal loop.
452
+
453
+ The indicator refreshes on goal commands and on idle, compaction, and interruption events — **not** on the `message.updated` events that stream during an assistant turn. Streaming refreshes would put an API round-trip in the response path for a cosmetic update, and idle is the cadence a human actually reads the indicator at.
454
+
455
+ The captured original title lives in memory only, so a hard process kill leaves the last status line on the session. The plugin recognizes its own status lines and will not mistake one for your title, so `/goal clear` after a restart leaves the host's title alone rather than restoring stale goal status — but it cannot recover the title the session had before the goal started. Rename the session if you want it back.
456
+
457
+ This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step.
458
+
459
+ ## Plan-mode safety
460
+
461
+ A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default:
462
+
463
+ - A goal set while `plan` is active is **recorded but held**, with stop reason `plan agent active`. The objective and its budget survive, so nothing is lost — the goal simply does not start.
464
+ - The routed confirmation text for a held goal **omits the "start working" instruction** and is sent as a read-only control turn. This matters because command text reaches the model as a normal turn on current OpenCode builds (see [Limitations](#limitations)).
465
+ - Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop.
466
+ - Continuations retain the agent that started the goal, so the loop cannot drift into a different agent.
467
+
468
+ The active agent is read from the execution context the host reports for its turns. OpenCode runs `command.execute.before` before any `chat.message`/`chat.params` for the turn, and its session record carries no agent, so for the first command in a session the agent is unknown at creation time. The plugin therefore re-checks when the routed turn reaches `chat.message`, which does carry the agent, and holds the goal there — rewriting the turn into a read-only control turn and blocking tools for it — before the model is told to start.
469
+
470
+ **Command configuration matters.** OpenCode runs a custom command under the agent named in its config (`command.goal.agent`) and only falls back to the agent selected in the session when the command sets none. With the install snippet's `"agent": "build"`, the `/goal` turn itself always executes as `build`, so a hold can only come from a *previously* reported planning-only agent (the case verified in the TUI, where you switched to Plan and then typed `/goal`). To have Plan mode hold a goal even on a session's very first turn, omit `agent` from the `goal` command config so the command runs under the selected agent.
471
+
472
+ **What this does and does not prevent.** The restriction stops the *goal loop*: a held goal sends zero auto-continues, so no unattended work happens. It cannot stop a model from acting on the single routed command turn, because OpenCode's `command.execute.before` does not fully intercept command text (see [Limitations](#limitations)). A held goal's routed text explicitly tells the model not to begin work and is sent as a read-only control turn, but a non-compliant model may still act on that one turn. Verified against OpenCode 1.18.25: a goal set under Plan records `stopped: true`, `stopReason: plan agent active`, and `turnCount: 0`.
473
+
474
+ Run `/goal resume` after switching back to an executing agent to start the work.
475
+
476
+ | Option | Default | Controls |
477
+ |---|---|---|
478
+ | `restrictedAgents` | `["plan"]` | Agent names treated as planning-only (case-insensitive). Pass `[]` to release the restriction. |
479
+ | `allowGoalExecutionFromPlan` | `false` | Set `true` to allow goal creation and auto-continue while a restricted agent is active. |
480
+
481
+ ```json
482
+ {
483
+ "plugin": [
484
+ ["opencode-goal-plugin", { "restrictedAgents": ["plan", "review"] }]
485
+ ]
486
+ }
487
+ ```
488
+
489
+ The restriction being on by default is pinned by the mutation contract: hardcoding `allowGoalExecutionFromPlan` to `true` fails the suite.
490
+
404
491
  ## Prompt safety
405
492
 
406
493
  The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
@@ -446,6 +533,8 @@ Point OpenCode at the source file directly for local testing:
446
533
 
447
534
  Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode will attempt to load plugin-like files it finds there.
448
535
 
536
+ A `file://` entry loads the source as-is, so the checkout needs its `node_modules` (`npm ci`) for the `zod` import to resolve; copying `src/goal-plugin.js` somewhere on its own will fail to load. Use the npm package for anything but development.
537
+
449
538
  ### Smoke-test checklist
450
539
 
451
540
  1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call.
@@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool
81
81
  blocking, and parent-correlated lifecycle suppression. The system transform
82
82
  remains registered as additional protection for hosts that support it.
83
83
 
84
+ ## OpenCode 2
85
+
86
+ **Status: not supported, and not yet tested.**
87
+
88
+ The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as
89
+ `>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made
90
+ without a verified run behind it, and the project has not yet exercised the
91
+ plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than
92
+ as known-broken.
93
+
94
+ ### What already exists in this direction
95
+
96
+ - `createOpenCodeSessionApi` speaks both the legacy generated-client shape
97
+ (`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`),
98
+ selected per operation and remembered after the first success. The
99
+ `sdkShape: "flat"` option pins the flattened shape for embedded clients.
100
+ - Only read-only operations are ever replayed against the alternate shape, so a
101
+ shape probe can never duplicate a mutating call. This invariant is pinned by
102
+ the mutation contract.
103
+
104
+ ### What a supported v2 claim would require
105
+
106
+ Before the pin is widened, all of the following need to pass against a real
107
+ OpenCode 2 build, not a mock:
108
+
109
+ 1. Plugin load and hook registration through the v2 plugin entrypoint.
110
+ 2. `command.execute.before`, `event`, `experimental.chat.system.transform`,
111
+ `experimental.session.compacting`, and `experimental.compaction.autocontinue`
112
+ firing with the shapes the plugin expects.
113
+ 3. The execution-context signals (`chat.message`, `chat.params`,
114
+ `session.updated`) still reporting the active agent, which the planning-only
115
+ restriction depends on.
116
+ 4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`,
117
+ `abort`) under whichever argument shape v2 ships.
118
+ 5. Goal-specific compaction context and recovery of running child sessions after
119
+ a plugin restart, which are the areas most likely to differ.
120
+
121
+ ### Configuration
122
+
123
+ This plugin is **server-only**: `package.json` exports the root and
124
+ `opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its
125
+ configuration therefore lives entirely in `opencode.json` (the `plugin` and
126
+ `command` keys) on any OpenCode line.
127
+
128
+ Plugins that *do* ship a TUI component are registered in a second file whose
129
+ location differs between OpenCode lines, and those formats must not be mixed.
130
+ That distinction does not apply here — including for the
131
+ [status indicator](../README.md#status-indicator), which reaches the TUI through
132
+ the session title rather than through a TUI plugin.
133
+
84
134
  ## Versioning
85
135
 
86
136
  Semantic-versioning intent is:
package/docs/releasing.md CHANGED
@@ -7,7 +7,10 @@ artifact after all checks pass.
7
7
  ## Prepare
8
8
 
9
9
  1. Start from a clean branch based on `main`.
10
- 2. Update the version in `package.json` and `package-lock.json` together.
10
+ 2. Update the version in `package.json` and `package-lock.json` together, and
11
+ the pinned `opencode-goal-plugin@X.Y.Z` in the README install section and
12
+ `examples/opencode.json` (OpenCode never refreshes an unpinned plugin, so
13
+ the docs must show a pin).
11
14
  3. Move relevant entries from `Unreleased` into a dated changelog section.
12
15
  4. Run `npm ci` followed by `npm run release:check`.
13
16
  5. Inspect `npm pack --json` and the generated tarball before publishing.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
- "plugin": ["opencode-goal-plugin"],
3
+ "plugin": ["opencode-goal-plugin@0.10.0"],
4
4
  "command": {
5
5
  "goal": {
6
6
  "description": "Set a session-scoped goal and auto-continue until complete.",
package/index.d.ts CHANGED
@@ -133,6 +133,17 @@ export interface GoalPluginOptions {
133
133
  */
134
134
  maxTokens?: number
135
135
 
136
+ /**
137
+ * Maximum cumulative API cost, in US dollars, a goal may incur before it is
138
+ * paused for exceeding limits. Uses the cost OpenCode reports on assistant
139
+ * messages, so enforcement depends on provider cost metadata and one
140
+ * response may overshoot the cap; an unknown cost never trips it.
141
+ * `/goal resume` opens a fresh budget window. Overridable per-goal with
142
+ * `--max-cost`. `0` disables the cap.
143
+ * @default 0
144
+ */
145
+ maxCostUsd?: number
146
+
136
147
  /**
137
148
  * Minimum delay, in milliseconds, enforced between consecutive
138
149
  * auto-continue prompts. Overridable per-goal with `--cooldown-ms`.
@@ -308,9 +319,50 @@ export interface GoalPluginOptions {
308
319
  */
309
320
  registerTools?: boolean
310
321
 
322
+ /**
323
+ * How much control the agent-facing tools have over goals. `"full"` (the
324
+ * default) lets `goal_set`/`set_goal` replace an active goal, `update_goal`
325
+ * rewrite the objective, and `clear_goal` discard goals. `"status"` keeps
326
+ * agents to reporting: they may complete, block, pause, or resume a goal
327
+ * and create one when none is live, but only the user, through the slash
328
+ * command, can replace, edit, or clear a goal. Refusals are returned as tool
329
+ * results (an `agent_authority` failure envelope for `goal_set`).
330
+ * @default "full"
331
+ */
332
+ agentGoalAuthority?: "full" | "status"
333
+
311
334
  /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
312
335
  registerAgents?: boolean
313
336
 
337
+ /**
338
+ * Mirror live goal status into the OpenCode session title, which the TUI
339
+ * renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`),
340
+ * giving unattended runs a continuous heartbeat without a TUI plugin.
341
+ *
342
+ * The session's original title is captured before the first overwrite and
343
+ * restored by `/goal clear`. A completed goal renders as `✅ … · N turns · …`
344
+ * until then. Title updates are cosmetic: a failure is logged at debug level
345
+ * and never interrupts the goal loop.
346
+ * @default false
347
+ */
348
+ sessionTitleStatus?: boolean
349
+
350
+ /**
351
+ * Agent names treated as planning-only. A goal created while one of these
352
+ * agents is active is recorded but held paused instead of starting, and
353
+ * auto-continue stays suppressed while one is active. Matching is
354
+ * case-insensitive. Pass `[]` to release the restriction entirely.
355
+ * @default ["plan"]
356
+ */
357
+ restrictedAgents?: string[]
358
+
359
+ /**
360
+ * Opt out of the planning-only restriction, allowing goals to be created and
361
+ * auto-continued while a {@link restrictedAgents} agent is active.
362
+ * @default false
363
+ */
364
+ allowGoalExecutionFromPlan?: boolean
365
+
314
366
  /** Name of the native primary goal agent. @default "goal" */
315
367
  goalAgentName?: string
316
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.8.2",
3
+ "version": "0.10.0",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -75,6 +75,6 @@
75
75
  "typescript": "7.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "zod": "4.4.3"
78
+ "zod": "4.5.4"
79
79
  }
80
80
  }
@@ -5,6 +5,10 @@
5
5
  // as scripts/smoke-command-hook.mjs.
6
6
 
7
7
  import assert from "node:assert/strict"
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
9
+ import { createRequire } from "node:module"
10
+ import { homedir } from "node:os"
11
+ import { dirname, join } from "node:path"
8
12
 
9
13
  const REQUIRED_HOOKS = [
10
14
  "config",
@@ -34,6 +38,8 @@ const EXPECTED_TOOLS = [
34
38
 
35
39
  const results = []
36
40
 
41
+ class VerificationWarning extends Error {}
42
+
37
43
  function check(name, fn) {
38
44
  return Promise.resolve()
39
45
  .then(fn)
@@ -41,6 +47,12 @@ function check(name, fn) {
41
47
  results.push({ name, ok: true })
42
48
  console.log(` ✅ ${name}`)
43
49
  })
50
+ .catch((error) => {
51
+ if (!(error instanceof VerificationWarning)) throw error
52
+ results.push({ name, ok: true, warning: error.message })
53
+ console.log(` ⚠️ ${name}`)
54
+ console.log(` ${error.message}`)
55
+ })
44
56
  .catch((error) => {
45
57
  results.push({ name, ok: false, error })
46
58
  console.log(` ❌ ${name}`)
@@ -164,6 +176,68 @@ await check("lifecycle transitions are visible without leaking objective text",
164
176
  assert.ok(logCalls.every((entry) => !entry.body.message.includes("verify the installation")))
165
177
  })
166
178
 
179
+ // OpenCode installs an unpinned plugin into its package cache once and never
180
+ // re-resolves `latest` while that directory exists, so a user can run a stale
181
+ // copy long after upgrading on npm. Warn (never fail) when the unpinned cache
182
+ // entries lag the package this script came from.
183
+ function installedPackageVersion() {
184
+ try {
185
+ let dir = dirname(createRequire(import.meta.url).resolve("opencode-goal-plugin"))
186
+ while (dir !== dirname(dir)) {
187
+ const pkg = join(dir, "package.json")
188
+ if (existsSync(pkg)) {
189
+ const json = JSON.parse(readFileSync(pkg, "utf8"))
190
+ if (json.name === "opencode-goal-plugin") return String(json.version || "")
191
+ }
192
+ dir = dirname(dir)
193
+ }
194
+ } catch {}
195
+ return ""
196
+ }
197
+
198
+ function versionBelow(a, b) {
199
+ const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0)
200
+ const [x, y] = [parse(a), parse(b)]
201
+ for (let i = 0; i < 3; i += 1) {
202
+ if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0)
203
+ }
204
+ return false
205
+ }
206
+
207
+ await check("OpenCode's cached copy of the plugin is not older than this package", () => {
208
+ const packageVersion = installedPackageVersion()
209
+ if (!packageVersion) return
210
+ const cacheRoots = [
211
+ process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, "opencode") : null,
212
+ join(homedir(), ".cache", "opencode"),
213
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "opencode") : null,
214
+ ].filter(Boolean)
215
+ const stale = []
216
+ for (const root of cacheRoots) {
217
+ const packages = join(root, "packages")
218
+ if (!existsSync(packages)) continue
219
+ for (const entry of readdirSync(packages)) {
220
+ // Only unpinned entries are affected; a pinned older version is a choice.
221
+ if (entry !== "opencode-goal-plugin" && entry !== "opencode-goal-plugin@latest") continue
222
+ const pkg = join(packages, entry, "node_modules", "opencode-goal-plugin", "package.json")
223
+ if (!existsSync(pkg)) continue
224
+ let cached = ""
225
+ try {
226
+ cached = String(JSON.parse(readFileSync(pkg, "utf8")).version || "")
227
+ } catch {
228
+ continue
229
+ }
230
+ if (cached && versionBelow(cached, packageVersion)) stale.push({ path: join(packages, entry), cached })
231
+ }
232
+ }
233
+ if (!stale.length) return
234
+ throw new VerificationWarning(
235
+ `OpenCode is running ${stale.map((s) => `${s.cached} from ${s.path}`).join(" and ")}, older than ${packageVersion}. ` +
236
+ `OpenCode never re-resolves an unpinned plugin: pin "opencode-goal-plugin@${packageVersion}" in opencode.json, ` +
237
+ "or delete that cache directory, then restart OpenCode.",
238
+ )
239
+ })
240
+
167
241
  console.log()
168
242
 
169
243
  const failed = results.filter((r) => !r.ok)
@@ -172,4 +246,7 @@ if (failed.length > 0) {
172
246
  process.exit(1)
173
247
  }
174
248
 
175
- console.log(`All ${results.length} checks passed. opencode-goal-plugin is installed correctly.`)
249
+ const warnings = results.filter((r) => r.warning).length
250
+ console.log(
251
+ `All ${results.length} checks passed${warnings ? ` with ${warnings} warning(s)` : ""}. opencode-goal-plugin is installed correctly.`,
252
+ )