diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f1619cd..985091a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,8 +20,8 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: javascript-typescript - name: Analyze - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5b328..a1ba507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 0.10.0 — 2026-09-06 + +- 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 ` 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. +- 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 `✅ · N turns · · `; `/goal clear` still restores the captured original title. Reproduced against live OpenCode 1.18.25 and 1.18.29. +- 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. +- 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 `` 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). +- 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. +- 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. +- 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). +- 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 @`, 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. + ## 0.9.0 — 2026-08-29 - 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. diff --git a/README.md b/README.md index 219df58..0ced736 100644 --- a/README.md +++ b/README.md @@ -47,24 +47,23 @@ Tested against real OpenCode 1.17.15 and 1.18.25 processes with live provider cr | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed | | 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 | | 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 | +| 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 | `/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. **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. +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. + 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. ## Install -```sh -npm install opencode-goal-plugin -``` - -Add the plugin and command to your OpenCode config: +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`): ```json { - "plugin": ["opencode-goal-plugin"], + "plugin": ["opencode-goal-plugin@0.10.0"], "command": { "goal": { "description": "Set a session-scoped goal and auto-continue until complete.", @@ -75,6 +74,20 @@ Add the plugin and command to your OpenCode config: } ``` +Or let the CLI add the plugin entry for you and then add the `command` block by hand: + +```sh +opencode plugin opencode-goal-plugin@0.10.0 --global +``` + +Restart OpenCode after editing the config. The options form `["opencode-goal-plugin@0.10.0", { ... }]` (see [Options](#options)) pins the same way. + +### Upgrading + +**Pin the version.** OpenCode resolves an unpinned `"opencode-goal-plugin"` entry to `@latest` exactly once, installs it under its package cache (`~/.cache/opencode/packages/opencode-goal-plugin@latest/` by default; `opencode debug paths` prints the cache root), and never re-resolves `latest` while that directory exists. An unpinned entry therefore stays on whichever version was first installed, indefinitely, and new releases on npm are never picked up — a bug fixed months ago can still be running locally. + +To upgrade, bump the pin (for example to `opencode-goal-plugin@0.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. + ## Usage Set a goal: @@ -86,7 +99,7 @@ Set a goal: Override limits for a single goal: ``` -/goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000 +/goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000 --max-cost 5 ``` Add success criteria, constraints / non-goals, and a mode: @@ -99,6 +112,14 @@ Add success criteria, constraints / non-goals, and a mode: 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. +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: + +````text +/goal run ```pytest --maxfail=1 --disable-warnings``` and fix every failure +```` + +Multiline fences work as well. The backticks are removed from the stored objective, and a fence is never consumed as a flag's value. + Check status: ``` @@ -231,6 +252,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu | Auto-continue turns | 10 | | Max duration | 15 minutes | | Context tokens | 200,000 | +| API cost (USD) | off — set `maxCostUsd` or `--max-cost` | | Min delay between continues | 1.5 seconds | | No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) | | 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 **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. +**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. + **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. **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 Additional plugin-level options: +- `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). - `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. - `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. - `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: - `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. - `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. - `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). +- `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). - `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. - `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. - `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. - `persistState` — whether to persist active goals and recent goal results to disk. - `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 `/.opencode/goals/state.json`; shards are written below `.sessions/` (see the persistence section above). +- `ledgerFilePath` — override where the lifecycle ledger is written. By default each session shard keeps its ledger next to its state file as `.ledger.jsonl`. - `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. - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory. - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted. @@ -357,7 +384,7 @@ Registered tools: - `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. - `get_goal`, `get_goal_history`, `set_goal`, `update_goal`, and `clear_goal` remain compatibility aliases with their existing text responses. -`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. +`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. 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. @@ -411,7 +438,7 @@ Unattended runs are easier to trust when you can see the goal is still alive. Se ▶ ship the release · 3/10 · 2m · 45k/200k ``` -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. +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. ```json { @@ -438,7 +465,9 @@ A planning-only agent is never driven into execution by the goal loop. OpenCode' - Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop. - Continuations retain the agent that started the goal, so the loop cannot drift into a different agent. -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. +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. + +**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. **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`. @@ -504,6 +533,8 @@ Point OpenCode at the source file directly for local testing: Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode will attempt to load plugin-like files it finds there. +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. + ### Smoke-test checklist 1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call. diff --git a/docs/releasing.md b/docs/releasing.md index 41fa655..18b0a06 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -7,7 +7,10 @@ artifact after all checks pass. ## Prepare 1. Start from a clean branch based on `main`. -2. Update the version in `package.json` and `package-lock.json` together. +2. Update the version in `package.json` and `package-lock.json` together, and + the pinned `opencode-goal-plugin@X.Y.Z` in the README install section and + `examples/opencode.json` (OpenCode never refreshes an unpinned plugin, so + the docs must show a pin). 3. Move relevant entries from `Unreleased` into a dated changelog section. 4. Run `npm ci` followed by `npm run release:check`. 5. Inspect `npm pack --json` and the generated tarball before publishing. diff --git a/examples/opencode.json b/examples/opencode.json index 144e292..f114545 100644 --- a/examples/opencode.json +++ b/examples/opencode.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-goal-plugin"], + "plugin": ["opencode-goal-plugin@0.10.0"], "command": { "goal": { "description": "Set a session-scoped goal and auto-continue until complete.", diff --git a/index.d.ts b/index.d.ts index 2d502ca..5b67565 100644 --- a/index.d.ts +++ b/index.d.ts @@ -133,6 +133,17 @@ export interface GoalPluginOptions { */ maxTokens?: number + /** + * Maximum cumulative API cost, in US dollars, a goal may incur before it is + * paused for exceeding limits. Uses the cost OpenCode reports on assistant + * messages, so enforcement depends on provider cost metadata and one + * response may overshoot the cap; an unknown cost never trips it. + * `/goal resume` opens a fresh budget window. Overridable per-goal with + * `--max-cost`. `0` disables the cap. + * @default 0 + */ + maxCostUsd?: number + /** * Minimum delay, in milliseconds, enforced between consecutive * auto-continue prompts. Overridable per-goal with `--cooldown-ms`. @@ -308,6 +319,18 @@ export interface GoalPluginOptions { */ registerTools?: boolean + /** + * How much control the agent-facing tools have over goals. `"full"` (the + * default) lets `goal_set`/`set_goal` replace an active goal, `update_goal` + * rewrite the objective, and `clear_goal` discard goals. `"status"` keeps + * agents to reporting: they may complete, block, pause, or resume a goal + * and create one when none is live, but only the user, through the slash + * command, can replace, edit, or clear a goal. Refusals are returned as tool + * results (an `agent_authority` failure envelope for `goal_set`). + * @default "full" + */ + agentGoalAuthority?: "full" | "status" + /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */ registerAgents?: boolean @@ -317,8 +340,9 @@ export interface GoalPluginOptions { * giving unattended runs a continuous heartbeat without a TUI plugin. * * The session's original title is captured before the first overwrite and - * restored by `/goal clear`. Title updates are cosmetic: a failure is logged - * at debug level and never interrupts the goal loop. + * restored by `/goal clear`. A completed goal renders as `✅ … · N turns · …` + * until then. Title updates are cosmetic: a failure is logged at debug level + * and never interrupts the goal loop. * @default false */ sessionTitleStatus?: boolean diff --git a/package-lock.json b/package-lock.json index 9e359f6..cbdd1ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "opencode-goal-plugin", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-goal-plugin", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "dependencies": { - "zod": "4.4.3" + "zod": "4.5.4" }, "bin": { "opencode-goal-plugin": "scripts/verify.mjs" @@ -398,9 +398,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index cabe4c1..0c7be30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-goal-plugin", - "version": "0.9.0", + "version": "0.10.0", "description": "Durable, guarded goal workflows for OpenCode.", "type": "module", "main": "./src/goal-plugin.js", @@ -75,6 +75,6 @@ "typescript": "7.0.2" }, "dependencies": { - "zod": "4.4.3" + "zod": "4.5.4" } } diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs index 9fce464..a970c8e 100644 --- a/scripts/mutation-contract.mjs +++ b/scripts/mutation-contract.mjs @@ -92,6 +92,13 @@ const mutants = [ to: "const allowGoalExecutionFromPlan = true", test: "test/goal-plugin.test.js", }, + { + name: "a planning-only agent learned from the routed command turn still holds the new goal", + file: "src/goal-plugin.js", + from: "if (commandTurn.startedGoal && commandTurn.attachmentError !== true) {", + to: "if (false) {", + test: "test/goal-plugin.test.js", + }, { name: "completion evidence must be adjacent", file: "src/goal-plugin.js", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index e8e746c..cd0ee87 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -5,6 +5,10 @@ // as scripts/smoke-command-hook.mjs. import assert from "node:assert/strict" +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { createRequire } from "node:module" +import { homedir } from "node:os" +import { dirname, join } from "node:path" const REQUIRED_HOOKS = [ "config", @@ -34,6 +38,8 @@ const EXPECTED_TOOLS = [ const results = [] +class VerificationWarning extends Error {} + function check(name, fn) { return Promise.resolve() .then(fn) @@ -41,6 +47,12 @@ function check(name, fn) { results.push({ name, ok: true }) console.log(` ✅ ${name}`) }) + .catch((error) => { + if (!(error instanceof VerificationWarning)) throw error + results.push({ name, ok: true, warning: error.message }) + console.log(` ⚠️ ${name}`) + console.log(` ${error.message}`) + }) .catch((error) => { results.push({ name, ok: false, error }) console.log(` ❌ ${name}`) @@ -164,6 +176,68 @@ await check("lifecycle transitions are visible without leaking objective text", assert.ok(logCalls.every((entry) => !entry.body.message.includes("verify the installation"))) }) +// OpenCode installs an unpinned plugin into its package cache once and never +// re-resolves `latest` while that directory exists, so a user can run a stale +// copy long after upgrading on npm. Warn (never fail) when the unpinned cache +// entries lag the package this script came from. +function installedPackageVersion() { + try { + let dir = dirname(createRequire(import.meta.url).resolve("opencode-goal-plugin")) + while (dir !== dirname(dir)) { + const pkg = join(dir, "package.json") + if (existsSync(pkg)) { + const json = JSON.parse(readFileSync(pkg, "utf8")) + if (json.name === "opencode-goal-plugin") return String(json.version || "") + } + dir = dirname(dir) + } + } catch {} + return "" +} + +function versionBelow(a, b) { + const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0) + const [x, y] = [parse(a), parse(b)] + for (let i = 0; i < 3; i += 1) { + if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0) + } + return false +} + +await check("OpenCode's cached copy of the plugin is not older than this package", () => { + const packageVersion = installedPackageVersion() + if (!packageVersion) return + const cacheRoots = [ + process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, "opencode") : null, + join(homedir(), ".cache", "opencode"), + process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "opencode") : null, + ].filter(Boolean) + const stale = [] + for (const root of cacheRoots) { + const packages = join(root, "packages") + if (!existsSync(packages)) continue + for (const entry of readdirSync(packages)) { + // Only unpinned entries are affected; a pinned older version is a choice. + if (entry !== "opencode-goal-plugin" && entry !== "opencode-goal-plugin@latest") continue + const pkg = join(packages, entry, "node_modules", "opencode-goal-plugin", "package.json") + if (!existsSync(pkg)) continue + let cached = "" + try { + cached = String(JSON.parse(readFileSync(pkg, "utf8")).version || "") + } catch { + continue + } + if (cached && versionBelow(cached, packageVersion)) stale.push({ path: join(packages, entry), cached }) + } + } + if (!stale.length) return + throw new VerificationWarning( + `OpenCode is running ${stale.map((s) => `${s.cached} from ${s.path}`).join(" and ")}, older than ${packageVersion}. ` + + `OpenCode never re-resolves an unpinned plugin: pin "opencode-goal-plugin@${packageVersion}" in opencode.json, ` + + "or delete that cache directory, then restart OpenCode.", + ) +}) + console.log() const failed = results.filter((r) => !r.ok) @@ -172,4 +246,7 @@ if (failed.length > 0) { process.exit(1) } -console.log(`All ${results.length} checks passed. opencode-goal-plugin is installed correctly.`) +const warnings = results.filter((r) => r.warning).length +console.log( + `All ${results.length} checks passed${warnings ? ` with ${warnings} warning(s)` : ""}. opencode-goal-plugin is installed correctly.`, +) diff --git a/src/goal-plugin.js b/src/goal-plugin.js index ead8b93..fa9749e 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -73,6 +73,9 @@ const DEFAULT_OPTIONS = { maxTurns: 10, maxDurationMs: 15 * 60 * 1000, maxTokens: 200000, + // Cumulative OpenCode-reported API cost, in US dollars, before the goal + // pauses. 0 disables the cap; enforcement depends on provider cost metadata. + maxCostUsd: 0, minDelayMs: 1500, maxRecentMessages: 50, noProgressTokenThreshold: 50, @@ -228,6 +231,8 @@ const GOAL_FLAG_SPECS = { // Inline budget shorthand for the context-token limit. Accepts a plain // integer or a k/m suffix (e.g. --budget 100k == --max-tokens 100000). "--budget": { type: "tokens", optionKey: "maxTokens" }, + // Per-goal cost cap in US dollars (e.g. --max-cost 5 or --max-cost 2.50). + "--max-cost": { type: "usd", optionKey: "maxCostUsd" }, "--success": { type: "string", target: "meta", metaKey: "successCriteria" }, "--success-criteria": { type: "string", target: "meta", metaKey: "successCriteria" }, "--constraints": { type: "string", target: "meta", metaKey: "constraints" }, @@ -303,6 +308,47 @@ function frameControlCommandText(text) { ].join("\n") } +// Routed text for the turn that creates a goal. A held goal must not be told +// to start working: command text reaches the model as a normal turn on current +// OpenCode builds, so that line would be the escape the plan guard exists to +// prevent. +function buildGoalCommandNotice(goal, { heldLabel = "", replacedGoal = null, commandName = "goal" } = {}) { + return [ + ...(replacedGoal + ? [ + `⚠️ Replacing active goal: "${replacedGoal.condition}"`, + `Use \`/${commandName} add \` instead to keep it running in the background.`, + "", + ] + : []), + heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`, + goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null, + goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null, + goal.mode !== "normal" ? `Mode: ${goal.mode}` : null, + "", + ...(heldLabel + ? [ + `The ${heldLabel} agent is planning-only, so this goal is not running.`, + "Do not begin work on it now. Continue planning only.", + `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`, + ] + : [ + "Start working toward this goal now.", + "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.", + "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.", + ]), + `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`, + "", + `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round( + goal.options.maxDurationMs / 1000, + )}s, ${goal.options.maxTokens.toLocaleString()} context tokens${ + goal.options.maxCostUsd > 0 ? `, $${goal.options.maxCostUsd.toFixed(2)} cost` : "" + }.`, + ] + .filter((line) => line !== null) + .join("\n") +} + // OpenCode retains its original command-parts array after invoking // command.execute.before. Reassigning output.parts therefore changes only the // temporary wrapper passed to the plugin, while the host still sends the raw @@ -432,7 +478,7 @@ function isPlanAgent(agent) { // continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it // overwrites a user-visible field. const SESSION_TITLE_OBJECTIVE_LIMIT = 48 -const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"] +const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔", "✅"] // The title sits in a narrow column, so every field is abbreviated hard. function formatCompactDuration(ms) { @@ -476,6 +522,18 @@ function buildSessionTitle(goal, now = Date.now()) { ].join(" · ") } +// Title for a goal that just completed. Archived results carry the counters +// but not the option snapshot, so the "/limit" halves are dropped. +function buildCompletedSessionTitle(result) { + const turns = toNonNegativeInteger(result.turnCount) + return [ + `✅ ${summarizeText(result.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`, + `${turns} turn${turns === 1 ? "" : "s"}`, + formatCompactDuration(Math.max(0, result.finishedAt - result.startedAt)), + formatCompactTokens(result.totalTokens), + ].join(" · ") +} + // Recognize a title this plugin wrote. The captured "original" is what // `/goal clear` restores, so capturing one of our own status lines would make // clear promote a stale status string to the permanent session title. That is @@ -817,6 +875,11 @@ function formatStatus( `Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`, `Context tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`, formatUsage(goal.usage), + ...(costCapFor(goal) + ? [ + `Cost budget: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(4)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}`, + ] + : []), `Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`, `Last progress: ${lastProgress}`, `No-progress turns: ${goal.noProgressTurns}`, @@ -881,9 +944,26 @@ function stopReason(goal) { return `max duration reached (${Math.round(goal.options.maxDurationMs / 1000)}s)` } if (goal.totalTokens >= goal.options.maxTokens) return `max context tokens reached (${goal.options.maxTokens.toLocaleString()})` + const costCap = costCapFor(goal) + if (costCap && costCap.reached) return `max cost reached ($${costCap.limit.toFixed(2)})` return null } +// Cost cap state, or null when the cap is disabled. The cap can only be +// enforced when the provider reports cost; an unknown cost never trips it. +function costCapFor(goal) { + const limit = Number(goal?.options?.maxCostUsd) + if (!Number.isFinite(limit) || limit <= 0) return null + const usage = normalizeUsage(goal.usage) + return { + limit, + spent: usage.cost, + known: usage.costKnown, + remaining: Math.max(0, limit - usage.cost), + reached: usage.costKnown && usage.cost >= limit, + } +} + function sessionGoalMap(sessionID) { let map = sessionGoals.get(sessionID) if (!map) { @@ -1264,6 +1344,10 @@ function normalizeOptions(options = {}) { maxTurns: toPositiveInteger(options.maxTurns, DEFAULT_OPTIONS.maxTurns), maxDurationMs: toPositiveInteger(options.maxDurationMs, DEFAULT_OPTIONS.maxDurationMs), maxTokens: toPositiveInteger(options.maxTokens, DEFAULT_OPTIONS.maxTokens), + maxCostUsd: + Number.isFinite(Number(options.maxCostUsd)) && Number(options.maxCostUsd) > 0 + ? Number(options.maxCostUsd) + : DEFAULT_OPTIONS.maxCostUsd, minDelayMs: toPositiveInteger(options.minDelayMs, DEFAULT_OPTIONS.minDelayMs), maxRecentMessages: toPositiveInteger( options.maxRecentMessages, @@ -2247,29 +2331,35 @@ async function logPluginDebug(client, message, error) { } } +// A fenced ```span``` in the arguments is objective text verbatim: double-dash +// tokens inside it are never parsed as goal flags and it is never consumed as +// a flag value, so a command line can be quoted inside an objective. function parseGoalArguments(args, defaults) { - const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || [] + const parts = Array.from( + args.matchAll(/```([\s\S]*?)```|"[^"]*"|'[^']*'|\S+/g), + (match) => ({ value: match[1] ?? match[0], literal: match[1] !== undefined }), + ) const condition = [] const options = { ...defaults } const meta = { ...GOAL_META_DEFAULTS } const errors = [] + const isFlagValue = (candidate) => + candidate !== undefined && !candidate.literal && !candidate.value.startsWith("--") for (let i = 0; i < parts.length; i += 1) { - const part = parts[i] + const { value: part, literal } = parts[i] - if (part.startsWith("--")) { + if (!literal && part.startsWith("--")) { const [flagName, inlineValue] = part.split(/=(.*)/s, 2) const flagSpec = GOAL_FLAG_SPECS[flagName] if (!flagSpec) { - const next = parts[i + 1] - if (inlineValue === undefined && next !== undefined && !next.startsWith("--")) i += 1 + if (inlineValue === undefined && isFlagValue(parts[i + 1])) i += 1 errors.push(`Unsupported flag: ${flagName}`) continue } - const next = parts[i + 1] - const value = inlineValue ?? (next !== undefined && !next.startsWith("--") ? next : undefined) + const value = inlineValue ?? (isFlagValue(parts[i + 1]) ? parts[i + 1].value : undefined) if (inlineValue === undefined && value !== undefined) i += 1 if (value === undefined) { @@ -2291,6 +2381,16 @@ function parseGoalArguments(args, defaults) { continue } + if (flagSpec.type === "usd") { + const cost = /^\$?\d+(?:\.\d+)?$/.test(rawValue.trim()) ? Number(rawValue.trim().replace(/^\$/, "")) : NaN + if (!Number.isFinite(cost) || cost <= 0) { + errors.push(`Invalid cost budget for ${flagName}: ${value} (use a positive number of US dollars)`) + continue + } + options[flagSpec.optionKey] = cost + continue + } + if (flagSpec.type === "string") { const text = rawValue.trim() if (!text) { @@ -2321,7 +2421,7 @@ function parseGoalArguments(args, defaults) { continue } - condition.push(stripWrappingQuotes(part)) + condition.push(literal ? part.trim() : stripWrappingQuotes(part)) } const parsedCondition = condition.join(" ").trim() @@ -2372,6 +2472,10 @@ function buildLimitWarning(goal) { if (remainingTokens <= goal.options.warnTokensRemaining) { warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} context token(s) remaining`) } + const costCap = costCapFor(goal) + if (costCap?.known && costCap.remaining <= costCap.limit * 0.1) { + warnings.push(`$${costCap.remaining.toFixed(2)} of the $${costCap.limit.toFixed(2)} cost budget remaining`) + } return warnings.length ? ` Limits are near: ${warnings.join(", ")}.` : "" } @@ -2465,6 +2569,9 @@ function buildContinueMessage( "", `turns_remaining: ${remainingTurns}`, `tokens_remaining: ${remainingTokens}`, + ...(costCapFor(goal) + ? [`cost_remaining_usd: ${costCapFor(goal).known ? costCapFor(goal).remaining.toFixed(2) : "unknown"}`] + : []), `elapsed_seconds: ${elapsedSeconds}`, "", ] @@ -2556,7 +2663,9 @@ function buildCompactionContext(goal) { "The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.", buildGoalBlock(goal), `Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`, - `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`, + `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.${ + costCapFor(goal) ? ` Cost: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(2)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}.` : "" + }`, goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(summarizeText(goal.lastCheckpoint.summary, 200))}` : null, ...buildCompactionProgressSummary(goal), "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({ auditMessagesEnabled = false, announceLifecycle = () => {}, commandName = "goal", + agentGoalAuthority = "full", }) { + // "status" authority: agents may report on a goal (complete, block, pause, + // resume) and create one when none is live, but only the user, through the + // slash command, may replace, edit, or clear a goal. Returns the refusal + // text, or null when the action is allowed. + function agentLockMessage(sessionID, action) { + if (agentGoalAuthority !== "status") return null + if (action === "replace" && !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0) { + return null + } + const verb = + action === "replace" + ? "replace the active goal" + : action === "edit" + ? "change the goal objective" + : "clear the goal" + const hint = + action === "replace" + ? `/${commandName} , /${commandName} add , or /${commandName} edit ` + : action === "edit" + ? `/${commandName} edit ` + : `/${commandName} clear` + return `Agents cannot ${verb} in this session (agentGoalAuthority: "status"). Ask the user to run ${hint}.` + } + // Use persistTerminalState (which logs on failure) for terminal operations when // available; fall back to plain persist for callers that don't wire it up (e.g. // tests using buildAgentToolHandlers directly). @@ -3189,6 +3323,8 @@ function buildAgentToolHandlers({ async function setGoal(sessionID, args = {}) { const objective = typeof args.objective === "string" ? args.objective.trim() : "" if (!objective) return "No objective provided. Pass a non-empty `objective`." + const replaceLock = agentLockMessage(sessionID, "replace") + if (replaceLock) return replaceLock if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) return `Invalid objective: must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.` for (const [field, value] of [["successCriteria", args.successCriteria], ["constraints", args.constraints]]) { @@ -3204,6 +3340,8 @@ function buildAgentToolHandlers({ return `Invalid maxTokens: ${args.maxTokens} — must be a positive integer.` if (Number.isFinite(args.maxDurationMs) && args.maxDurationMs <= 0) return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.` + if (Number.isFinite(args.maxCostUsd) && args.maxCostUsd <= 0) + return `Invalid maxCostUsd: ${args.maxCostUsd} — must be a positive number of US dollars.` if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase())) return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).` const options = normalizeOptions({ @@ -3211,6 +3349,7 @@ function buildAgentToolHandlers({ ...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}), ...(Number.isFinite(args.maxTokens) ? { maxTokens: args.maxTokens } : {}), ...(Number.isFinite(args.maxDurationMs) ? { maxDurationMs: args.maxDurationMs } : {}), + ...(Number.isFinite(args.maxCostUsd) ? { maxCostUsd: args.maxCostUsd } : {}), }) const meta = { successCriteria: typeof args.successCriteria === "string" ? args.successCriteria : "", @@ -3248,6 +3387,10 @@ function buildAgentToolHandlers({ async function updateGoal(sessionID, args = {}) { let goal = goalStates.get(sessionID) if (!goal) return "No active goal to update. Use set_goal first." + if (typeof args.objective === "string" && args.objective.trim()) { + const editLock = agentLockMessage(sessionID, "edit") + if (editLock) return editLock + } // Reject the combination of an objective update with status='complete': the // completion would be archived under a condition that was never executed, @@ -3553,6 +3696,8 @@ function buildAgentToolHandlers({ } async function clearGoal(sessionID) { + const clearLock = agentLockMessage(sessionID, "clear") + if (clearLock) return clearLock // Mirror `/goal clear`: drop the ordered flag, ALL backgrounded goals, and the // focused goal + result. Without sessionGoals.delete, background goals added via // `/goal add` survive clear and resurrect as the focused goal on restart. @@ -3586,7 +3731,7 @@ function buildAgentToolHandlers({ : "Goal cleared." } - return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal } + return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal, agentLockMessage } } function agentToolSessionID(ctx) { @@ -3696,6 +3841,8 @@ function buildAgentTools( if (typeof args.objective !== "string" || !args.objective.trim()) { return goalToolFailure("invalid_objective", "No objective provided. Pass a non-empty objective.") } + const locked = handlers.agentLockMessage?.(sessionID, "replace") + if (locked) return goalToolFailure("agent_authority", locked) return goalToolSuccess(await handlers.setGoal(sessionID, args)) }, update: async (sessionID, args) => { @@ -3742,6 +3889,7 @@ function buildAgentTools( maxTurns: schema.number().optional(), maxTokens: schema.number().optional(), maxDurationMs: schema.number().optional(), + maxCostUsd: schema.number().optional(), successCriteria: schema.string().optional(), constraints: schema.string().optional(), mode: schema.string().optional(), @@ -3804,6 +3952,7 @@ function buildAgentTools( maxTurns: schema.number().optional(), maxTokens: schema.number().optional(), maxDurationMs: schema.number().optional(), + maxCostUsd: schema.number().optional(), successCriteria: schema.string().optional(), constraints: schema.string().optional(), mode: schema.string().optional(), @@ -4071,6 +4220,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }) const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions) const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents) + const agentGoalAuthority = pluginOptions.agentGoalAuthority === "status" ? "status" : "full" // Opt-out for deployments that deliberately drive execution from a planning // agent. Defaults to false: unattended work must not escape Plan mode. const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true @@ -4085,8 +4235,18 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const syncSessionTitle = async (sessionID) => { if (!sessionTitleStatus || !sessionID) return const goal = goalStates.get(sessionID) - if (!goal) return - const title = buildSessionTitle(goal) + let title + if (goal) { + title = buildSessionTitle(goal) + } else { + // No live goal. Completion archives the goal, so without this branch + // the last "running" line would stay on the session until /goal clear. + // Only rewrite a title this process already owns, and only for an + // achieved result; clear still restores the captured original. + const result = lastGoalResults.get(sessionID) + if (!currentRuntime().appliedTitles.has(sessionID) || result?.state !== "achieved") return + title = buildCompletedSessionTitle(result) + } if (currentRuntime().appliedTitles.get(sessionID) === title) return try { if (!currentRuntime().sessionTitles.has(sessionID)) { @@ -4456,6 +4616,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) auditMessagesEnabled, announceLifecycle, commandName, + agentGoalAuthority, }) const abortAcceptedContinuation = async (sessionID) => { @@ -4929,6 +5090,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // mutate it in place just as command.execute.before does. message.parts.splice(0, message.parts.length, commandPart) } + // A goal created by the first command of a fresh session could not + // know the active agent at creation time (command.execute.before runs + // before any chat hook and the Session record carries no agent). The + // routed turn does carry it: re-evaluate the planning-only restriction + // and hold the goal before the model is told to start working. + if (commandTurn.startedGoal && commandTurn.attachmentError !== true) { + const startedGoal = goalStates.get(sessionID) + const startedByThisCommand = + Boolean(startedGoal) && + !startedGoal.stopped && + startedGoal.goalId === commandTurn.startedGoal.goalId && + startedGoal.runId === commandTurn.startedGoal.runId + const lateRestrictedAgent = startedByThisCommand ? await restrictedAgentFor(sessionID) : "" + if (lateRestrictedAgent) { + const heldLabel = holdGoalForRestrictedAgent(startedGoal, lateRestrictedAgent) + await persist(sessionID) + announceLifecycle(sessionID, `Goal recorded but held while ${heldLabel} is active.`, { + goal: startedGoal, + transition: "paused", + expectedState: "paused", + }) + const commandPart = pluginMarkedTextPart(message, "command") + const routedText = frameControlCommandText( + buildGoalCommandNotice(startedGoal, { heldLabel, commandName }), + ) + commandPart.text = routedText + commandTurn.policy = "control" + commandTurn.textDigest = createHash("sha256").update(routedText).digest("hex") + } + } runtime.activeCommandTurns.set(sessionID, { ...commandTurn, messageID: currentMessageID, @@ -5481,6 +5672,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) registerSessionGoal(goal) focusGoal(sessionID, goal) await persist(sessionID) + // The agent is often unknown here: OpenCode runs command.execute.before + // before any chat hook for the turn and its Session record carries no + // agent. Remember which goal this command started so chat.message, which + // does receive the agent, can still hold it (see that hook). + const creationCommandTurn = currentRuntime().commandOutputs.get(output) + if (creationCommandTurn && !creationRestrictedAgent) { + creationCommandTurn.startedGoal = { goalId: goal.goalId, runId: goal.runId } + } const heldLabel = creationRestrictedAgent ? isPlanAgent(creationRestrictedAgent) ? "Plan" @@ -5499,47 +5698,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) expectedState: heldLabel ? "paused" : "active", }, ) - replaceCommandOutputText( - output, - [ - ...(replacedGoal - ? [ - `⚠️ Replacing active goal: "${replacedGoal.condition}"`, - `Use \`/${commandName} add \` instead to keep it running in the background.`, - "", - ] - : []), - heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`, - goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null, - goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null, - goal.mode !== "normal" ? `Mode: ${goal.mode}` : null, - "", - // A held goal must not be told to start working. Command text reaches - // the model as a normal turn on current OpenCode builds, so this line - // would be the escape the plan guard exists to prevent. - ...(heldLabel - ? [ - `The ${heldLabel} agent is planning-only, so this goal is not running.`, - "Do not begin work on it now. Continue planning only.", - `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`, - ] - : [ - "Start working toward this goal now.", - "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.", - "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.", - ]), - `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`, - "", - `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round( - goal.options.maxDurationMs / 1000, - )}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`, - ] - .filter((line) => line !== null) - .join("\n"), + replaceCommandOutputText(output, buildGoalCommandNotice(goal, { heldLabel, replacedGoal, commandName }), { + preserveFiles: true, // A held goal is a control turn, not a work turn: `startsWork: false` // routes it through the read-only command framing. - { preserveFiles: true, startsWork: !heldLabel }, - ) + startsWork: !heldLabel, + }) }, event: async ({ event }) => { @@ -6896,6 +7060,7 @@ export const testInternals = { isPluginContinuationMessage, isPlanAgent, buildSessionTitle, + buildCompletedSessionTitle, formatCompactDuration, formatCompactTokens, goalStatusIcon, @@ -6921,5 +7086,6 @@ export const testInternals = { resolveStateFilePath, runtimeSessionDiagnostics, stopReason, + costCapFor, xdgStateFilePath, } diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 12d8c94..d4c8f2c 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -38,6 +38,7 @@ const { isPluginContinuationMessage, isPlanAgent, buildSessionTitle, + buildCompletedSessionTitle, formatCompactDuration, formatCompactTokens, goalStatusIcon, @@ -65,6 +66,7 @@ const { sessionPathsFor, setLedgerSink, stopReason, + costCapFor, totalTokensForMessage, userInterventionDetected, xdgStateFilePath, @@ -9878,7 +9880,7 @@ async function createTitleHooks(overrides = {}) { const client = { app: { log: async () => {} }, session: { - messages: async () => ({ data: [message("still working")] }), + messages: overrides.messages || (async () => ({ data: [message("still working")] })), promptAsync: async () => ({}), abort: async () => ({}), get: overrides.get || (async () => ({ data: { title: "my original title" } })), @@ -9947,6 +9949,14 @@ test("buildSessionTitle renders a compact one-line status", () => { assert.ok(title.length < 100, `title should stay compact, got ${title.length}`) }) +test("buildCompletedSessionTitle renders the terminal state without limit halves", () => { + const now = Date.now() + const result = { condition: "ship the release", turnCount: 3, totalTokens: 45_000, startedAt: now - 120_000, finishedAt: now } + assert.equal(buildCompletedSessionTitle(result), "✅ ship the release · 3 turns · 2m · 45k") + assert.equal(buildCompletedSessionTitle({ ...result, turnCount: 1 }), "✅ ship the release · 1 turn · 2m · 45k") + assert.equal(looksLikePluginSessionTitle(buildCompletedSessionTitle(result)), true) +}) + test("looksLikePluginSessionTitle recognizes titles this plugin wrote", () => { assert.equal(looksLikePluginSessionTitle("▶ ship it · 3/10 · 2m · 45k/200k"), true) assert.equal(looksLikePluginSessionTitle("⏸ ship it · 3/10 · 2m · 45k/200k"), true) @@ -10174,3 +10184,279 @@ test("a cached execution context is preferred over refetching the session", asyn assert.equal(currentGoal("session-1").stopped, false, "the cached build context must win") assert.equal(getCalls, 0, "a known agent must not cost a session fetch") }) + +async function routeFirstCommandTurn(hooks, sessionID, args, agent) { + const messageID = `msg-first-${sessionID}` + const output = { message: { id: messageID, role: "user", sessionID }, parts: [textPart(`/goal ${args}`)] } + await hooks["command.execute.before"]({ command: "goal", sessionID, arguments: args }, output) + const creationText = output.parts[0].text + output.parts = resolveRoutedParts(output.parts, sessionID, messageID) + await hooks["chat.message"]({ sessionID, messageID, agent }, output) + return { output, creationText } +} + +test("a goal created by the first command of a Plan session is held once the routed turn reveals the agent", async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + const sessionID = "session-plan-first" + const { output, creationText } = await routeFirstCommandTurn(hooks, sessionID, "ship it", "plan") + + // At creation no agent was known: OpenCode's Session record carries none and + // no chat hook has run yet, so the goal started live. + assert.match(creationText, /Start working toward this goal now\./) + + const goal = currentGoal(sessionID) + assert.equal(goal.stopped, true, "the routed turn's agent must hold the goal") + assert.equal(goal.stopReason, "plan agent active") + assert.deepEqual(goal.history.map((entry) => entry.type), ["set", "paused"]) + + const text = output.parts[0].text + assert.match(text, //, "the turn must be re-routed as a read-only control turn") + assert.match(text, /Goal recorded but held: ship it/) + assert.match(text, /Do not begin work on it now/) + assert.ok(!text.includes("Start working toward this goal now."), "the work instruction must be gone") + + await assert.rejects( + hooks["tool.execute.before"]({ sessionID, tool: "bash" }), + /control command has already been handled/, + "tools are blocked for the held turn", + ) + await hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + assert.equal(calls.length, 0, "a held goal must never auto-continue") +}) + +test("a goal created by the first command under an executing agent stays live", async () => { + const sessionID = "session-build-first" + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1 }, + messages: async () => ({ + data: [message("still working", undefined, "msg-build-first", sessionID, `msg-first-${sessionID}`)], + }), + }) + const { output } = await routeFirstCommandTurn(hooks, sessionID, "ship it", "build") + assert.equal(currentGoal(sessionID).stopped, false) + assert.match(output.parts[0].text, /Start working toward this goal now\./) + await hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1) +}) + +test("allowGoalExecutionFromPlan keeps a first-command Plan goal live", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1, allowGoalExecutionFromPlan: true } }) + const sessionID = "session-plan-allowed" + const { output } = await routeFirstCommandTurn(hooks, sessionID, "ship it", "plan") + assert.equal(currentGoal(sessionID).stopped, false) + assert.match(output.parts[0].text, /Start working toward this goal now\./) +}) + +test("a completion marker on the held Plan turn does not complete the goal", async () => { + const sessionID = "session-plan-complete" + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1 }, + messages: async () => ({ + data: [ + message("[goal:evidence] ran everything\n[goal:complete]", undefined, "msg-held-turn", sessionID, `msg-first-${sessionID}`), + ], + }), + }) + await routeFirstCommandTurn(hooks, sessionID, "ship it", "plan") + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { id: "msg-held-turn", role: "assistant", sessionID, tokens: { input: 1, output: 20, reasoning: 0 } }, + }, + }, + }) + await hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + const goal = currentGoal(sessionID) + assert.ok(goal, "the held goal must still be live in memory, not archived") + assert.equal(goal.stopped, true) + assert.equal(goal.stopReason, "plan agent active") + assert.equal(calls.length, 0) +}) + + +test("sessionTitleStatus shows a completed goal instead of a stale running line", async () => { + const { hooks, updates } = await createTitleHooks({ + messages: async () => ({ data: [message("[goal:evidence] ran the suite\n[goal:complete]")] }), + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + assert.match(updates.at(-1).body.title, /^▶ ship it · 0\/\d+ · /) + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.ok(!currentGoal("session-1"), "the goal must have completed and been archived") + assert.match(updates.at(-1).body.title, /^✅ ship it · 0 turns · \d+s · \d+$/) + + // A later read-only command re-renders the same line and skips the API call. + const renders = updates.length + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "status" }, + { parts: [] }, + ) + assert.equal(updates.length, renders) + assert.match(updates.at(-1).body.title, /^✅ ship it/) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "clear" }, + { parts: [] }, + ) + assert.equal(updates.at(-1).body.title, "my original title") +}) + +test("sessionTitleStatus never writes a completion line for a session this process did not title", async () => { + // Restart: the archived result is visible but no title was applied by this process. + const { hooks, updates } = await createTitleHooks({ + messages: async () => ({ data: [message("[goal:evidence] ran the suite\n[goal:complete]")] }), + }) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-untitled", status: { type: "idle" } } }, + }) + assert.equal(updates.length, 0) +}) + +test("fenced code spans in the objective are literal text, not goal flags", () => { + const parsed = parseGoalArguments( + "run ```pytest --maxfail=1 --disable-warnings``` and fix every failure --max-turns 3", + normalizeOptions(), + ) + assert.deepEqual(parsed.errors, []) + assert.equal(parsed.condition, "run pytest --maxfail=1 --disable-warnings and fix every failure") + assert.equal(parsed.options.maxTurns, 3) + + const multiline = parseGoalArguments("fix ```\nnpm test -- --watch=false\n``` please", normalizeOptions()) + assert.deepEqual(multiline.errors, []) + assert.equal(multiline.condition, "fix npm test -- --watch=false please") + + // A fence is never consumed as a flag value, for known or unknown flags. + const asValue = parseGoalArguments("ship --success ```--flag``` it", normalizeOptions()) + assert.ok(asValue.errors.some((error) => error.startsWith("Missing value for --success"))) + assert.equal(asValue.condition, "ship --flag it") + const unknown = parseGoalArguments("ship --bogus ```literal``` it", normalizeOptions()) + assert.deepEqual(unknown.errors, ["Unsupported flag: --bogus"]) + assert.equal(unknown.condition, "ship literal it") + + // Without a fence the old behavior is unchanged. + const plain = parseGoalArguments("run pytest --maxfail=1", normalizeOptions()) + assert.deepEqual(plain.errors, ["Unsupported flag: --maxfail"]) +}) + +test("--max-cost sets a per-goal cost cap and rejects non-positive or malformed values", () => { + const parsed = parseGoalArguments("ship it --max-cost 2.5", normalizeOptions()) + assert.deepEqual(parsed.errors, []) + assert.equal(parsed.options.maxCostUsd, 2.5) + assert.equal(parseGoalArguments("ship it --max-cost=$5", normalizeOptions()).options.maxCostUsd, 5) + for (const bad of ["0", "-1", "abc", "1e3"]) { + const rejected = parseGoalArguments(`ship it --max-cost ${bad}`, normalizeOptions()) + assert.ok(rejected.errors.some((error) => error.startsWith("Invalid cost budget for --max-cost")), bad) + } + assert.equal(normalizeOptions().maxCostUsd, 0) + assert.equal(normalizeOptions({ maxCostUsd: -1 }).maxCostUsd, 0) + assert.equal(normalizeOptions({ maxCostUsd: "3" }).maxCostUsd, 3) +}) + +test("stopReason and costCapFor enforce the cost cap only when the provider reports cost", () => { + const base = { turnCount: 0, totalTokens: 0, startedAt: Date.now(), options: normalizeOptions({ maxCostUsd: 1 }) } + assert.equal(costCapFor({ ...base, options: normalizeOptions() }), null) + assert.equal(stopReason({ ...base, usage: { cost: 1.2, costKnown: true } }), "max cost reached ($1.00)") + assert.equal(stopReason({ ...base, usage: { cost: 0.4, costKnown: true } }), null) + assert.equal(stopReason({ ...base, usage: { cost: 0, costKnown: false } }), null) + const nearCap = costCapFor({ ...base, usage: { cost: 0.95, costKnown: true } }) + assert.deepEqual({ ...nearCap, remaining: Number(nearCap.remaining.toFixed(6)) }, { + limit: 1, + spent: 0.95, + known: true, + remaining: 0.05, + reached: false, + }) + assert.match(buildLimitWarning({ ...base, usage: { cost: 0.95, costKnown: true } }), /\$0\.05 of the \$1\.00 cost budget remaining/) +}) + +test("cost cap requests a final handoff and pauses the goal", async () => { + const sessionID = "session-cost" + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1, maxCostUsd: 1 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "assistant-costly", + role: "assistant", + sessionID, + tokens: { input: 10, output: 5, reasoning: 0 }, + cost: 1.5, + }, + }, + }, + }) + const goal = currentGoal(sessionID) + assert.match(formatStatus(goal, "goal"), /Cost budget: \$1\.5000\/\$1\.00/) + await hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1) + assert.match(calls[0].body.parts[0].text, //) + assert.equal(goal.stopped, true) + assert.equal(goal.stopReason, "max cost reached ($1.00)") +}) + +test("agent set_goal accepts and validates maxCostUsd", async () => { + const { handlers } = makeAgentHandlers() + assert.match(await handlers.setGoal("agent-cost-bad", { objective: "ship it", maxCostUsd: -2 }), /Invalid maxCostUsd/) + await handlers.setGoal("agent-cost", { objective: "ship it", maxCostUsd: 2 }) + assert.equal(currentGoal("agent-cost").options.maxCostUsd, 2) +}) + +test("agentGoalAuthority: status keeps agent tools to reporting", async () => { + const { handlers } = makeAgentHandlers({ agentGoalAuthority: "status" }) + const sid = "agent-authority" + // Creating a goal when none is live is allowed. + assert.match(await handlers.setGoal(sid, { objective: "ship it" }), /New active goal: ship it/) + // Replacing, editing, and clearing are not. + assert.match(await handlers.setGoal(sid, { objective: "do something else" }), /Agents cannot replace the active goal/) + assert.equal(currentGoal(sid).condition, "ship it") + assert.match(await handlers.updateGoal(sid, { objective: "rewritten" }), /Agents cannot change the goal objective/) + assert.equal(currentGoal(sid).condition, "ship it") + assert.match(await handlers.clearGoal(sid), /Agents cannot clear the goal/) + assert.ok(currentGoal(sid)) + // Status reporting still works. + assert.match(await handlers.updateGoal(sid, { status: "paused" }), /paused/i) + assert.equal(currentGoal(sid).stopped, true) + assert.match(await handlers.updateGoal(sid, { status: "resumed" }), /resumed/i) + assert.equal(currentGoal(sid).stopped, false) +}) + +test("agentGoalAuthority defaults to full, preserving replacement", async () => { + const { handlers } = makeAgentHandlers() + const sid = "agent-authority-full" + await handlers.setGoal(sid, { objective: "first" }) + assert.match(await handlers.setGoal(sid, { objective: "second" }), /New active goal: second/) + assert.equal(currentGoal(sid).condition, "second") + assert.match(await handlers.updateGoal(sid, { objective: "third" }), /Objective updated: third/) +}) + +test("canonical goal_set returns an agent_authority failure envelope under status authority", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1, agentGoalAuthority: "status" } }) + const sessionID = "canonical-authority" + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: "ship it" }, + { parts: [] }, + ) + const result = JSON.parse(await hooks.tool.goal_set.execute({ objective: "replace it" }, { sessionID })) + assert.equal(result.ok, false) + assert.equal(result.error, "agent_authority") + assert.equal(currentGoal(sessionID).condition, "ship it") +}) diff --git a/test/persistence-lease.test.js b/test/persistence-lease.test.js index 7f0eada..81e13f7 100644 --- a/test/persistence-lease.test.js +++ b/test/persistence-lease.test.js @@ -726,12 +726,17 @@ test("simultaneous stale observers cannot both acquire the replacement lease", a try { const first = persistenceLeaseInternals.acquirePersistenceLeaseWithHooks(state, {}, hooks) const second = persistenceLeaseInternals.acquirePersistenceLeaseWithHooks(state, {}, hooks) - await bothAtBefore + // Under load one acquirer can lose before it reaches a barrier (it + // re-inspects claims between attempts). Attach the settle handler first so + // that rejection is never reported as unhandled, and never wait at a + // barrier for an acquirer that has already settled. + const settled = Promise.allSettled([first, second]) + await Promise.race([bothAtBefore, settled]) releaseBefore() - await bothAtAfter + await Promise.race([bothAtAfter, settled]) releaseAfter() - const results = await Promise.allSettled([first, second]) + const results = await settled leases = results .filter((result) => result.status === "fulfilled") .map((result) => result.value)