opencode-goal-plugin 0.9.0 → 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,17 @@
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
+
5
16
  ## 0.9.0 — 2026-08-29
6
17
 
7
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.
package/README.md CHANGED
@@ -47,24 +47,23 @@ Tested against real OpenCode 1.17.15 and 1.18.25 processes with live provider cr
47
47
  | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
48
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
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 |
50
51
 
51
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.
52
53
 
53
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.
54
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
+
55
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.
56
59
 
57
60
  ## Install
58
61
 
59
- ```sh
60
- npm install opencode-goal-plugin
61
- ```
62
-
63
- 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`):
64
63
 
65
64
  ```json
66
65
  {
67
- "plugin": ["opencode-goal-plugin"],
66
+ "plugin": ["opencode-goal-plugin@0.10.0"],
68
67
  "command": {
69
68
  "goal": {
70
69
  "description": "Set a session-scoped goal and auto-continue until complete.",
@@ -75,6 +74,20 @@ Add the plugin and command to your OpenCode config:
75
74
  }
76
75
  ```
77
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
+
78
91
  ## Usage
79
92
 
80
93
  Set a goal:
@@ -86,7 +99,7 @@ Set a goal:
86
99
  Override limits for a single goal:
87
100
 
88
101
  ```
89
- /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
90
103
  ```
91
104
 
92
105
  Add success criteria, constraints / non-goals, and a mode:
@@ -99,6 +112,14 @@ Add success criteria, constraints / non-goals, and a mode:
99
112
 
100
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.
101
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
+
102
123
  Check status:
103
124
 
104
125
  ```
@@ -231,6 +252,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
231
252
  | Auto-continue turns | 10 |
232
253
  | Max duration | 15 minutes |
233
254
  | Context tokens | 200,000 |
255
+ | API cost (USD) | off — set `maxCostUsd` or `--max-cost` |
234
256
  | Min delay between continues | 1.5 seconds |
235
257
  | No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) |
236
258
  | Budget wrap-up threshold | 80% of context token budget |
@@ -240,6 +262,8 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
240
262
 
241
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.
242
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
+
243
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.
244
268
 
245
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.
@@ -328,6 +352,7 @@ Pass options when registering the plugin to change the defaults for all goals. T
328
352
 
329
353
  Additional plugin-level options:
330
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).
331
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.
332
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.
333
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.
@@ -337,11 +362,13 @@ Additional plugin-level options:
337
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.
338
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.
339
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).
340
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.
341
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.
342
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.
343
369
  - `persistState` — whether to persist active goals and recent goal results to disk.
344
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`.
345
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.
346
373
  - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
347
374
  - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
@@ -357,7 +384,7 @@ Registered tools:
357
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.
358
385
  - `get_goal`, `get_goal_history`, `set_goal`, `update_goal`, and `clear_goal` remain compatibility aliases with their existing text responses.
359
386
 
360
- `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.
361
388
 
362
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.
363
390
 
@@ -411,7 +438,7 @@ Unattended runs are easier to trust when you can see the goal is still alive. Se
411
438
  ▶ ship the release · 3/10 · 2m · 45k/200k
412
439
  ```
413
440
 
414
- 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.
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.
415
442
 
416
443
  ```json
417
444
  {
@@ -438,7 +465,9 @@ A planning-only agent is never driven into execution by the goal loop. OpenCode'
438
465
  - Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop.
439
466
  - Continuations retain the agent that started the goal, so the loop cannot drift into a different agent.
440
467
 
441
- The active agent is read from the execution context the host reports, falling back to the session record. That fallback matters: OpenCode runs `command.execute.before` before any `chat.message`/`chat.params` for the turn, so the context is empty for the first command in a session — the exact case a freshly opened Plan-mode session hits.
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.
442
471
 
443
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`.
444
473
 
@@ -504,6 +533,8 @@ Point OpenCode at the source file directly for local testing:
504
533
 
505
534
  Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode will attempt to load plugin-like files it finds there.
506
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
+
507
538
  ### Smoke-test checklist
508
539
 
509
540
  1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call.
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,6 +319,18 @@ 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
 
@@ -317,8 +340,9 @@ export interface GoalPluginOptions {
317
340
  * giving unattended runs a continuous heartbeat without a TUI plugin.
318
341
  *
319
342
  * The session's original title is captured before the first overwrite and
320
- * restored by `/goal clear`. Title updates are cosmetic: a failure is logged
321
- * at debug level and never interrupts the goal loop.
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.
322
346
  * @default false
323
347
  */
324
348
  sessionTitleStatus?: boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.9.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.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
+ )
@@ -73,6 +73,9 @@ const DEFAULT_OPTIONS = {
73
73
  maxTurns: 10,
74
74
  maxDurationMs: 15 * 60 * 1000,
75
75
  maxTokens: 200000,
76
+ // Cumulative OpenCode-reported API cost, in US dollars, before the goal
77
+ // pauses. 0 disables the cap; enforcement depends on provider cost metadata.
78
+ maxCostUsd: 0,
76
79
  minDelayMs: 1500,
77
80
  maxRecentMessages: 50,
78
81
  noProgressTokenThreshold: 50,
@@ -228,6 +231,8 @@ const GOAL_FLAG_SPECS = {
228
231
  // Inline budget shorthand for the context-token limit. Accepts a plain
229
232
  // integer or a k/m suffix (e.g. --budget 100k == --max-tokens 100000).
230
233
  "--budget": { type: "tokens", optionKey: "maxTokens" },
234
+ // Per-goal cost cap in US dollars (e.g. --max-cost 5 or --max-cost 2.50).
235
+ "--max-cost": { type: "usd", optionKey: "maxCostUsd" },
231
236
  "--success": { type: "string", target: "meta", metaKey: "successCriteria" },
232
237
  "--success-criteria": { type: "string", target: "meta", metaKey: "successCriteria" },
233
238
  "--constraints": { type: "string", target: "meta", metaKey: "constraints" },
@@ -303,6 +308,47 @@ function frameControlCommandText(text) {
303
308
  ].join("\n")
304
309
  }
305
310
 
311
+ // Routed text for the turn that creates a goal. A held goal must not be told
312
+ // to start working: command text reaches the model as a normal turn on current
313
+ // OpenCode builds, so that line would be the escape the plan guard exists to
314
+ // prevent.
315
+ function buildGoalCommandNotice(goal, { heldLabel = "", replacedGoal = null, commandName = "goal" } = {}) {
316
+ return [
317
+ ...(replacedGoal
318
+ ? [
319
+ `⚠️ Replacing active goal: "${replacedGoal.condition}"`,
320
+ `Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
321
+ "",
322
+ ]
323
+ : []),
324
+ heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
325
+ goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
326
+ goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
327
+ goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
328
+ "",
329
+ ...(heldLabel
330
+ ? [
331
+ `The ${heldLabel} agent is planning-only, so this goal is not running.`,
332
+ "Do not begin work on it now. Continue planning only.",
333
+ `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
334
+ ]
335
+ : [
336
+ "Start working toward this goal now.",
337
+ "When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
338
+ "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
339
+ ]),
340
+ `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
341
+ "",
342
+ `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
343
+ goal.options.maxDurationMs / 1000,
344
+ )}s, ${goal.options.maxTokens.toLocaleString()} context tokens${
345
+ goal.options.maxCostUsd > 0 ? `, $${goal.options.maxCostUsd.toFixed(2)} cost` : ""
346
+ }.`,
347
+ ]
348
+ .filter((line) => line !== null)
349
+ .join("\n")
350
+ }
351
+
306
352
  // OpenCode retains its original command-parts array after invoking
307
353
  // command.execute.before. Reassigning output.parts therefore changes only the
308
354
  // temporary wrapper passed to the plugin, while the host still sends the raw
@@ -432,7 +478,7 @@ function isPlanAgent(agent) {
432
478
  // continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it
433
479
  // overwrites a user-visible field.
434
480
  const SESSION_TITLE_OBJECTIVE_LIMIT = 48
435
- const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"]
481
+ const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔", "✅"]
436
482
 
437
483
  // The title sits in a narrow column, so every field is abbreviated hard.
438
484
  function formatCompactDuration(ms) {
@@ -476,6 +522,18 @@ function buildSessionTitle(goal, now = Date.now()) {
476
522
  ].join(" · ")
477
523
  }
478
524
 
525
+ // Title for a goal that just completed. Archived results carry the counters
526
+ // but not the option snapshot, so the "/limit" halves are dropped.
527
+ function buildCompletedSessionTitle(result) {
528
+ const turns = toNonNegativeInteger(result.turnCount)
529
+ return [
530
+ `✅ ${summarizeText(result.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
531
+ `${turns} turn${turns === 1 ? "" : "s"}`,
532
+ formatCompactDuration(Math.max(0, result.finishedAt - result.startedAt)),
533
+ formatCompactTokens(result.totalTokens),
534
+ ].join(" · ")
535
+ }
536
+
479
537
  // Recognize a title this plugin wrote. The captured "original" is what
480
538
  // `/goal clear` restores, so capturing one of our own status lines would make
481
539
  // clear promote a stale status string to the permanent session title. That is
@@ -817,6 +875,11 @@ function formatStatus(
817
875
  `Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
818
876
  `Context tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`,
819
877
  formatUsage(goal.usage),
878
+ ...(costCapFor(goal)
879
+ ? [
880
+ `Cost budget: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(4)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}`,
881
+ ]
882
+ : []),
820
883
  `Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
821
884
  `Last progress: ${lastProgress}`,
822
885
  `No-progress turns: ${goal.noProgressTurns}`,
@@ -881,9 +944,26 @@ function stopReason(goal) {
881
944
  return `max duration reached (${Math.round(goal.options.maxDurationMs / 1000)}s)`
882
945
  }
883
946
  if (goal.totalTokens >= goal.options.maxTokens) return `max context tokens reached (${goal.options.maxTokens.toLocaleString()})`
947
+ const costCap = costCapFor(goal)
948
+ if (costCap && costCap.reached) return `max cost reached ($${costCap.limit.toFixed(2)})`
884
949
  return null
885
950
  }
886
951
 
952
+ // Cost cap state, or null when the cap is disabled. The cap can only be
953
+ // enforced when the provider reports cost; an unknown cost never trips it.
954
+ function costCapFor(goal) {
955
+ const limit = Number(goal?.options?.maxCostUsd)
956
+ if (!Number.isFinite(limit) || limit <= 0) return null
957
+ const usage = normalizeUsage(goal.usage)
958
+ return {
959
+ limit,
960
+ spent: usage.cost,
961
+ known: usage.costKnown,
962
+ remaining: Math.max(0, limit - usage.cost),
963
+ reached: usage.costKnown && usage.cost >= limit,
964
+ }
965
+ }
966
+
887
967
  function sessionGoalMap(sessionID) {
888
968
  let map = sessionGoals.get(sessionID)
889
969
  if (!map) {
@@ -1264,6 +1344,10 @@ function normalizeOptions(options = {}) {
1264
1344
  maxTurns: toPositiveInteger(options.maxTurns, DEFAULT_OPTIONS.maxTurns),
1265
1345
  maxDurationMs: toPositiveInteger(options.maxDurationMs, DEFAULT_OPTIONS.maxDurationMs),
1266
1346
  maxTokens: toPositiveInteger(options.maxTokens, DEFAULT_OPTIONS.maxTokens),
1347
+ maxCostUsd:
1348
+ Number.isFinite(Number(options.maxCostUsd)) && Number(options.maxCostUsd) > 0
1349
+ ? Number(options.maxCostUsd)
1350
+ : DEFAULT_OPTIONS.maxCostUsd,
1267
1351
  minDelayMs: toPositiveInteger(options.minDelayMs, DEFAULT_OPTIONS.minDelayMs),
1268
1352
  maxRecentMessages: toPositiveInteger(
1269
1353
  options.maxRecentMessages,
@@ -2247,29 +2331,35 @@ async function logPluginDebug(client, message, error) {
2247
2331
  }
2248
2332
  }
2249
2333
 
2334
+ // A fenced ```span``` in the arguments is objective text verbatim: double-dash
2335
+ // tokens inside it are never parsed as goal flags and it is never consumed as
2336
+ // a flag value, so a command line can be quoted inside an objective.
2250
2337
  function parseGoalArguments(args, defaults) {
2251
- const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
2338
+ const parts = Array.from(
2339
+ args.matchAll(/```([\s\S]*?)```|"[^"]*"|'[^']*'|\S+/g),
2340
+ (match) => ({ value: match[1] ?? match[0], literal: match[1] !== undefined }),
2341
+ )
2252
2342
  const condition = []
2253
2343
  const options = { ...defaults }
2254
2344
  const meta = { ...GOAL_META_DEFAULTS }
2255
2345
  const errors = []
2346
+ const isFlagValue = (candidate) =>
2347
+ candidate !== undefined && !candidate.literal && !candidate.value.startsWith("--")
2256
2348
 
2257
2349
  for (let i = 0; i < parts.length; i += 1) {
2258
- const part = parts[i]
2350
+ const { value: part, literal } = parts[i]
2259
2351
 
2260
- if (part.startsWith("--")) {
2352
+ if (!literal && part.startsWith("--")) {
2261
2353
  const [flagName, inlineValue] = part.split(/=(.*)/s, 2)
2262
2354
  const flagSpec = GOAL_FLAG_SPECS[flagName]
2263
2355
 
2264
2356
  if (!flagSpec) {
2265
- const next = parts[i + 1]
2266
- if (inlineValue === undefined && next !== undefined && !next.startsWith("--")) i += 1
2357
+ if (inlineValue === undefined && isFlagValue(parts[i + 1])) i += 1
2267
2358
  errors.push(`Unsupported flag: ${flagName}`)
2268
2359
  continue
2269
2360
  }
2270
2361
 
2271
- const next = parts[i + 1]
2272
- const value = inlineValue ?? (next !== undefined && !next.startsWith("--") ? next : undefined)
2362
+ const value = inlineValue ?? (isFlagValue(parts[i + 1]) ? parts[i + 1].value : undefined)
2273
2363
  if (inlineValue === undefined && value !== undefined) i += 1
2274
2364
 
2275
2365
  if (value === undefined) {
@@ -2291,6 +2381,16 @@ function parseGoalArguments(args, defaults) {
2291
2381
  continue
2292
2382
  }
2293
2383
 
2384
+ if (flagSpec.type === "usd") {
2385
+ const cost = /^\$?\d+(?:\.\d+)?$/.test(rawValue.trim()) ? Number(rawValue.trim().replace(/^\$/, "")) : NaN
2386
+ if (!Number.isFinite(cost) || cost <= 0) {
2387
+ errors.push(`Invalid cost budget for ${flagName}: ${value} (use a positive number of US dollars)`)
2388
+ continue
2389
+ }
2390
+ options[flagSpec.optionKey] = cost
2391
+ continue
2392
+ }
2393
+
2294
2394
  if (flagSpec.type === "string") {
2295
2395
  const text = rawValue.trim()
2296
2396
  if (!text) {
@@ -2321,7 +2421,7 @@ function parseGoalArguments(args, defaults) {
2321
2421
  continue
2322
2422
  }
2323
2423
 
2324
- condition.push(stripWrappingQuotes(part))
2424
+ condition.push(literal ? part.trim() : stripWrappingQuotes(part))
2325
2425
  }
2326
2426
 
2327
2427
  const parsedCondition = condition.join(" ").trim()
@@ -2372,6 +2472,10 @@ function buildLimitWarning(goal) {
2372
2472
  if (remainingTokens <= goal.options.warnTokensRemaining) {
2373
2473
  warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} context token(s) remaining`)
2374
2474
  }
2475
+ const costCap = costCapFor(goal)
2476
+ if (costCap?.known && costCap.remaining <= costCap.limit * 0.1) {
2477
+ warnings.push(`$${costCap.remaining.toFixed(2)} of the $${costCap.limit.toFixed(2)} cost budget remaining`)
2478
+ }
2375
2479
 
2376
2480
  return warnings.length ? ` Limits are near: ${warnings.join(", ")}.` : ""
2377
2481
  }
@@ -2465,6 +2569,9 @@ function buildContinueMessage(
2465
2569
  "<progress_budget>",
2466
2570
  `turns_remaining: ${remainingTurns}`,
2467
2571
  `tokens_remaining: ${remainingTokens}`,
2572
+ ...(costCapFor(goal)
2573
+ ? [`cost_remaining_usd: ${costCapFor(goal).known ? costCapFor(goal).remaining.toFixed(2) : "unknown"}`]
2574
+ : []),
2468
2575
  `elapsed_seconds: ${elapsedSeconds}`,
2469
2576
  "</progress_budget>",
2470
2577
  ]
@@ -2556,7 +2663,9 @@ function buildCompactionContext(goal) {
2556
2663
  "The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.",
2557
2664
  buildGoalBlock(goal),
2558
2665
  `Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
2559
- `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
2666
+ `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.${
2667
+ costCapFor(goal) ? ` Cost: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(2)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}.` : ""
2668
+ }`,
2560
2669
  goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(summarizeText(goal.lastCheckpoint.summary, 200))}` : null,
2561
2670
  ...buildCompactionProgressSummary(goal),
2562
2671
  "After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] (preceded by a [goal:evidence] line) only when fully satisfied, or [goal:blocked] (preceded by a concrete blocker) only if user input is required.",
@@ -3149,7 +3258,32 @@ function buildAgentToolHandlers({
3149
3258
  auditMessagesEnabled = false,
3150
3259
  announceLifecycle = () => {},
3151
3260
  commandName = "goal",
3261
+ agentGoalAuthority = "full",
3152
3262
  }) {
3263
+ // "status" authority: agents may report on a goal (complete, block, pause,
3264
+ // resume) and create one when none is live, but only the user, through the
3265
+ // slash command, may replace, edit, or clear a goal. Returns the refusal
3266
+ // text, or null when the action is allowed.
3267
+ function agentLockMessage(sessionID, action) {
3268
+ if (agentGoalAuthority !== "status") return null
3269
+ if (action === "replace" && !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0) {
3270
+ return null
3271
+ }
3272
+ const verb =
3273
+ action === "replace"
3274
+ ? "replace the active goal"
3275
+ : action === "edit"
3276
+ ? "change the goal objective"
3277
+ : "clear the goal"
3278
+ const hint =
3279
+ action === "replace"
3280
+ ? `/${commandName} <objective>, /${commandName} add <objective>, or /${commandName} edit <objective>`
3281
+ : action === "edit"
3282
+ ? `/${commandName} edit <objective>`
3283
+ : `/${commandName} clear`
3284
+ return `Agents cannot ${verb} in this session (agentGoalAuthority: "status"). Ask the user to run ${hint}.`
3285
+ }
3286
+
3153
3287
  // Use persistTerminalState (which logs on failure) for terminal operations when
3154
3288
  // available; fall back to plain persist for callers that don't wire it up (e.g.
3155
3289
  // tests using buildAgentToolHandlers directly).
@@ -3189,6 +3323,8 @@ function buildAgentToolHandlers({
3189
3323
  async function setGoal(sessionID, args = {}) {
3190
3324
  const objective = typeof args.objective === "string" ? args.objective.trim() : ""
3191
3325
  if (!objective) return "No objective provided. Pass a non-empty `objective`."
3326
+ const replaceLock = agentLockMessage(sessionID, "replace")
3327
+ if (replaceLock) return replaceLock
3192
3328
  if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH)
3193
3329
  return `Invalid objective: must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`
3194
3330
  for (const [field, value] of [["successCriteria", args.successCriteria], ["constraints", args.constraints]]) {
@@ -3204,6 +3340,8 @@ function buildAgentToolHandlers({
3204
3340
  return `Invalid maxTokens: ${args.maxTokens} — must be a positive integer.`
3205
3341
  if (Number.isFinite(args.maxDurationMs) && args.maxDurationMs <= 0)
3206
3342
  return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
3343
+ if (Number.isFinite(args.maxCostUsd) && args.maxCostUsd <= 0)
3344
+ return `Invalid maxCostUsd: ${args.maxCostUsd} — must be a positive number of US dollars.`
3207
3345
  if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
3208
3346
  return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
3209
3347
  const options = normalizeOptions({
@@ -3211,6 +3349,7 @@ function buildAgentToolHandlers({
3211
3349
  ...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
3212
3350
  ...(Number.isFinite(args.maxTokens) ? { maxTokens: args.maxTokens } : {}),
3213
3351
  ...(Number.isFinite(args.maxDurationMs) ? { maxDurationMs: args.maxDurationMs } : {}),
3352
+ ...(Number.isFinite(args.maxCostUsd) ? { maxCostUsd: args.maxCostUsd } : {}),
3214
3353
  })
3215
3354
  const meta = {
3216
3355
  successCriteria: typeof args.successCriteria === "string" ? args.successCriteria : "",
@@ -3248,6 +3387,10 @@ function buildAgentToolHandlers({
3248
3387
  async function updateGoal(sessionID, args = {}) {
3249
3388
  let goal = goalStates.get(sessionID)
3250
3389
  if (!goal) return "No active goal to update. Use set_goal first."
3390
+ if (typeof args.objective === "string" && args.objective.trim()) {
3391
+ const editLock = agentLockMessage(sessionID, "edit")
3392
+ if (editLock) return editLock
3393
+ }
3251
3394
 
3252
3395
  // Reject the combination of an objective update with status='complete': the
3253
3396
  // completion would be archived under a condition that was never executed,
@@ -3553,6 +3696,8 @@ function buildAgentToolHandlers({
3553
3696
  }
3554
3697
 
3555
3698
  async function clearGoal(sessionID) {
3699
+ const clearLock = agentLockMessage(sessionID, "clear")
3700
+ if (clearLock) return clearLock
3556
3701
  // Mirror `/goal clear`: drop the ordered flag, ALL backgrounded goals, and the
3557
3702
  // focused goal + result. Without sessionGoals.delete, background goals added via
3558
3703
  // `/goal add` survive clear and resurrect as the focused goal on restart.
@@ -3586,7 +3731,7 @@ function buildAgentToolHandlers({
3586
3731
  : "Goal cleared."
3587
3732
  }
3588
3733
 
3589
- return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
3734
+ return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal, agentLockMessage }
3590
3735
  }
3591
3736
 
3592
3737
  function agentToolSessionID(ctx) {
@@ -3696,6 +3841,8 @@ function buildAgentTools(
3696
3841
  if (typeof args.objective !== "string" || !args.objective.trim()) {
3697
3842
  return goalToolFailure("invalid_objective", "No objective provided. Pass a non-empty objective.")
3698
3843
  }
3844
+ const locked = handlers.agentLockMessage?.(sessionID, "replace")
3845
+ if (locked) return goalToolFailure("agent_authority", locked)
3699
3846
  return goalToolSuccess(await handlers.setGoal(sessionID, args))
3700
3847
  },
3701
3848
  update: async (sessionID, args) => {
@@ -3742,6 +3889,7 @@ function buildAgentTools(
3742
3889
  maxTurns: schema.number().optional(),
3743
3890
  maxTokens: schema.number().optional(),
3744
3891
  maxDurationMs: schema.number().optional(),
3892
+ maxCostUsd: schema.number().optional(),
3745
3893
  successCriteria: schema.string().optional(),
3746
3894
  constraints: schema.string().optional(),
3747
3895
  mode: schema.string().optional(),
@@ -3804,6 +3952,7 @@ function buildAgentTools(
3804
3952
  maxTurns: schema.number().optional(),
3805
3953
  maxTokens: schema.number().optional(),
3806
3954
  maxDurationMs: schema.number().optional(),
3955
+ maxCostUsd: schema.number().optional(),
3807
3956
  successCriteria: schema.string().optional(),
3808
3957
  constraints: schema.string().optional(),
3809
3958
  mode: schema.string().optional(),
@@ -4071,6 +4220,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4071
4220
  })
4072
4221
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
4073
4222
  const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents)
4223
+ const agentGoalAuthority = pluginOptions.agentGoalAuthority === "status" ? "status" : "full"
4074
4224
  // Opt-out for deployments that deliberately drive execution from a planning
4075
4225
  // agent. Defaults to false: unattended work must not escape Plan mode.
4076
4226
  const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true
@@ -4085,8 +4235,18 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4085
4235
  const syncSessionTitle = async (sessionID) => {
4086
4236
  if (!sessionTitleStatus || !sessionID) return
4087
4237
  const goal = goalStates.get(sessionID)
4088
- if (!goal) return
4089
- const title = buildSessionTitle(goal)
4238
+ let title
4239
+ if (goal) {
4240
+ title = buildSessionTitle(goal)
4241
+ } else {
4242
+ // No live goal. Completion archives the goal, so without this branch
4243
+ // the last "running" line would stay on the session until /goal clear.
4244
+ // Only rewrite a title this process already owns, and only for an
4245
+ // achieved result; clear still restores the captured original.
4246
+ const result = lastGoalResults.get(sessionID)
4247
+ if (!currentRuntime().appliedTitles.has(sessionID) || result?.state !== "achieved") return
4248
+ title = buildCompletedSessionTitle(result)
4249
+ }
4090
4250
  if (currentRuntime().appliedTitles.get(sessionID) === title) return
4091
4251
  try {
4092
4252
  if (!currentRuntime().sessionTitles.has(sessionID)) {
@@ -4456,6 +4616,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4456
4616
  auditMessagesEnabled,
4457
4617
  announceLifecycle,
4458
4618
  commandName,
4619
+ agentGoalAuthority,
4459
4620
  })
4460
4621
 
4461
4622
  const abortAcceptedContinuation = async (sessionID) => {
@@ -4929,6 +5090,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4929
5090
  // mutate it in place just as command.execute.before does.
4930
5091
  message.parts.splice(0, message.parts.length, commandPart)
4931
5092
  }
5093
+ // A goal created by the first command of a fresh session could not
5094
+ // know the active agent at creation time (command.execute.before runs
5095
+ // before any chat hook and the Session record carries no agent). The
5096
+ // routed turn does carry it: re-evaluate the planning-only restriction
5097
+ // and hold the goal before the model is told to start working.
5098
+ if (commandTurn.startedGoal && commandTurn.attachmentError !== true) {
5099
+ const startedGoal = goalStates.get(sessionID)
5100
+ const startedByThisCommand =
5101
+ Boolean(startedGoal) &&
5102
+ !startedGoal.stopped &&
5103
+ startedGoal.goalId === commandTurn.startedGoal.goalId &&
5104
+ startedGoal.runId === commandTurn.startedGoal.runId
5105
+ const lateRestrictedAgent = startedByThisCommand ? await restrictedAgentFor(sessionID) : ""
5106
+ if (lateRestrictedAgent) {
5107
+ const heldLabel = holdGoalForRestrictedAgent(startedGoal, lateRestrictedAgent)
5108
+ await persist(sessionID)
5109
+ announceLifecycle(sessionID, `Goal recorded but held while ${heldLabel} is active.`, {
5110
+ goal: startedGoal,
5111
+ transition: "paused",
5112
+ expectedState: "paused",
5113
+ })
5114
+ const commandPart = pluginMarkedTextPart(message, "command")
5115
+ const routedText = frameControlCommandText(
5116
+ buildGoalCommandNotice(startedGoal, { heldLabel, commandName }),
5117
+ )
5118
+ commandPart.text = routedText
5119
+ commandTurn.policy = "control"
5120
+ commandTurn.textDigest = createHash("sha256").update(routedText).digest("hex")
5121
+ }
5122
+ }
4932
5123
  runtime.activeCommandTurns.set(sessionID, {
4933
5124
  ...commandTurn,
4934
5125
  messageID: currentMessageID,
@@ -5481,6 +5672,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5481
5672
  registerSessionGoal(goal)
5482
5673
  focusGoal(sessionID, goal)
5483
5674
  await persist(sessionID)
5675
+ // The agent is often unknown here: OpenCode runs command.execute.before
5676
+ // before any chat hook for the turn and its Session record carries no
5677
+ // agent. Remember which goal this command started so chat.message, which
5678
+ // does receive the agent, can still hold it (see that hook).
5679
+ const creationCommandTurn = currentRuntime().commandOutputs.get(output)
5680
+ if (creationCommandTurn && !creationRestrictedAgent) {
5681
+ creationCommandTurn.startedGoal = { goalId: goal.goalId, runId: goal.runId }
5682
+ }
5484
5683
  const heldLabel = creationRestrictedAgent
5485
5684
  ? isPlanAgent(creationRestrictedAgent)
5486
5685
  ? "Plan"
@@ -5499,47 +5698,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5499
5698
  expectedState: heldLabel ? "paused" : "active",
5500
5699
  },
5501
5700
  )
5502
- replaceCommandOutputText(
5503
- output,
5504
- [
5505
- ...(replacedGoal
5506
- ? [
5507
- `⚠️ Replacing active goal: "${replacedGoal.condition}"`,
5508
- `Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
5509
- "",
5510
- ]
5511
- : []),
5512
- heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
5513
- goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
5514
- goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
5515
- goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
5516
- "",
5517
- // A held goal must not be told to start working. Command text reaches
5518
- // the model as a normal turn on current OpenCode builds, so this line
5519
- // would be the escape the plan guard exists to prevent.
5520
- ...(heldLabel
5521
- ? [
5522
- `The ${heldLabel} agent is planning-only, so this goal is not running.`,
5523
- "Do not begin work on it now. Continue planning only.",
5524
- `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
5525
- ]
5526
- : [
5527
- "Start working toward this goal now.",
5528
- "When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
5529
- "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
5530
- ]),
5531
- `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
5532
- "",
5533
- `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
5534
- goal.options.maxDurationMs / 1000,
5535
- )}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
5536
- ]
5537
- .filter((line) => line !== null)
5538
- .join("\n"),
5701
+ replaceCommandOutputText(output, buildGoalCommandNotice(goal, { heldLabel, replacedGoal, commandName }), {
5702
+ preserveFiles: true,
5539
5703
  // A held goal is a control turn, not a work turn: `startsWork: false`
5540
5704
  // routes it through the read-only command framing.
5541
- { preserveFiles: true, startsWork: !heldLabel },
5542
- )
5705
+ startsWork: !heldLabel,
5706
+ })
5543
5707
  },
5544
5708
 
5545
5709
  event: async ({ event }) => {
@@ -6896,6 +7060,7 @@ export const testInternals = {
6896
7060
  isPluginContinuationMessage,
6897
7061
  isPlanAgent,
6898
7062
  buildSessionTitle,
7063
+ buildCompletedSessionTitle,
6899
7064
  formatCompactDuration,
6900
7065
  formatCompactTokens,
6901
7066
  goalStatusIcon,
@@ -6921,5 +7086,6 @@ export const testInternals = {
6921
7086
  resolveStateFilePath,
6922
7087
  runtimeSessionDiagnostics,
6923
7088
  stopReason,
7089
+ costCapFor,
6924
7090
  xdgStateFilePath,
6925
7091
  }