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 +18 -0
- package/README.md +98 -9
- package/docs/compatibility.md +50 -0
- package/docs/releasing.md +4 -1
- package/examples/opencode.json +1 -1
- package/index.d.ts +52 -0
- package/package.json +2 -2
- package/scripts/verify.mjs +78 -1
- package/src/goal-plugin.js +509 -48
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
|
-
|
|
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.
|
package/docs/compatibility.md
CHANGED
|
@@ -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.
|
package/examples/opencode.json
CHANGED
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.
|
|
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
|
|
78
|
+
"zod": "4.5.4"
|
|
79
79
|
}
|
|
80
80
|
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
)
|