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 +11 -0
- package/README.md +41 -10
- package/docs/releasing.md +4 -1
- package/examples/opencode.json +1 -1
- package/index.d.ts +26 -2
- package/package.json +2 -2
- package/scripts/verify.mjs +78 -1
- package/src/goal-plugin.js +218 -52
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
|
-
|
|
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
|
|
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.
|
package/examples/opencode.json
CHANGED
package/index.d.ts
CHANGED
|
@@ -133,6 +133,17 @@ export interface GoalPluginOptions {
|
|
|
133
133
|
*/
|
|
134
134
|
maxTokens?: number
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Maximum cumulative API cost, in US dollars, a goal may incur before it is
|
|
138
|
+
* paused for exceeding limits. Uses the cost OpenCode reports on assistant
|
|
139
|
+
* messages, so enforcement depends on provider cost metadata and one
|
|
140
|
+
* response may overshoot the cap; an unknown cost never trips it.
|
|
141
|
+
* `/goal resume` opens a fresh budget window. Overridable per-goal with
|
|
142
|
+
* `--max-cost`. `0` disables the cap.
|
|
143
|
+
* @default 0
|
|
144
|
+
*/
|
|
145
|
+
maxCostUsd?: number
|
|
146
|
+
|
|
136
147
|
/**
|
|
137
148
|
* Minimum delay, in milliseconds, enforced between consecutive
|
|
138
149
|
* auto-continue prompts. Overridable per-goal with `--cooldown-ms`.
|
|
@@ -308,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`.
|
|
321
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Durable, guarded goal workflows for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -75,6 +75,6 @@
|
|
|
75
75
|
"typescript": "7.0.2"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"zod": "4.4
|
|
78
|
+
"zod": "4.5.4"
|
|
79
79
|
}
|
|
80
80
|
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// as scripts/smoke-command-hook.mjs.
|
|
6
6
|
|
|
7
7
|
import assert from "node:assert/strict"
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
9
|
+
import { createRequire } from "node:module"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
8
12
|
|
|
9
13
|
const REQUIRED_HOOKS = [
|
|
10
14
|
"config",
|
|
@@ -34,6 +38,8 @@ const EXPECTED_TOOLS = [
|
|
|
34
38
|
|
|
35
39
|
const results = []
|
|
36
40
|
|
|
41
|
+
class VerificationWarning extends Error {}
|
|
42
|
+
|
|
37
43
|
function check(name, fn) {
|
|
38
44
|
return Promise.resolve()
|
|
39
45
|
.then(fn)
|
|
@@ -41,6 +47,12 @@ function check(name, fn) {
|
|
|
41
47
|
results.push({ name, ok: true })
|
|
42
48
|
console.log(` ✅ ${name}`)
|
|
43
49
|
})
|
|
50
|
+
.catch((error) => {
|
|
51
|
+
if (!(error instanceof VerificationWarning)) throw error
|
|
52
|
+
results.push({ name, ok: true, warning: error.message })
|
|
53
|
+
console.log(` ⚠️ ${name}`)
|
|
54
|
+
console.log(` ${error.message}`)
|
|
55
|
+
})
|
|
44
56
|
.catch((error) => {
|
|
45
57
|
results.push({ name, ok: false, error })
|
|
46
58
|
console.log(` ❌ ${name}`)
|
|
@@ -164,6 +176,68 @@ await check("lifecycle transitions are visible without leaking objective text",
|
|
|
164
176
|
assert.ok(logCalls.every((entry) => !entry.body.message.includes("verify the installation")))
|
|
165
177
|
})
|
|
166
178
|
|
|
179
|
+
// OpenCode installs an unpinned plugin into its package cache once and never
|
|
180
|
+
// re-resolves `latest` while that directory exists, so a user can run a stale
|
|
181
|
+
// copy long after upgrading on npm. Warn (never fail) when the unpinned cache
|
|
182
|
+
// entries lag the package this script came from.
|
|
183
|
+
function installedPackageVersion() {
|
|
184
|
+
try {
|
|
185
|
+
let dir = dirname(createRequire(import.meta.url).resolve("opencode-goal-plugin"))
|
|
186
|
+
while (dir !== dirname(dir)) {
|
|
187
|
+
const pkg = join(dir, "package.json")
|
|
188
|
+
if (existsSync(pkg)) {
|
|
189
|
+
const json = JSON.parse(readFileSync(pkg, "utf8"))
|
|
190
|
+
if (json.name === "opencode-goal-plugin") return String(json.version || "")
|
|
191
|
+
}
|
|
192
|
+
dir = dirname(dir)
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
return ""
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function versionBelow(a, b) {
|
|
199
|
+
const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0)
|
|
200
|
+
const [x, y] = [parse(a), parse(b)]
|
|
201
|
+
for (let i = 0; i < 3; i += 1) {
|
|
202
|
+
if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0)
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await check("OpenCode's cached copy of the plugin is not older than this package", () => {
|
|
208
|
+
const packageVersion = installedPackageVersion()
|
|
209
|
+
if (!packageVersion) return
|
|
210
|
+
const cacheRoots = [
|
|
211
|
+
process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, "opencode") : null,
|
|
212
|
+
join(homedir(), ".cache", "opencode"),
|
|
213
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "opencode") : null,
|
|
214
|
+
].filter(Boolean)
|
|
215
|
+
const stale = []
|
|
216
|
+
for (const root of cacheRoots) {
|
|
217
|
+
const packages = join(root, "packages")
|
|
218
|
+
if (!existsSync(packages)) continue
|
|
219
|
+
for (const entry of readdirSync(packages)) {
|
|
220
|
+
// Only unpinned entries are affected; a pinned older version is a choice.
|
|
221
|
+
if (entry !== "opencode-goal-plugin" && entry !== "opencode-goal-plugin@latest") continue
|
|
222
|
+
const pkg = join(packages, entry, "node_modules", "opencode-goal-plugin", "package.json")
|
|
223
|
+
if (!existsSync(pkg)) continue
|
|
224
|
+
let cached = ""
|
|
225
|
+
try {
|
|
226
|
+
cached = String(JSON.parse(readFileSync(pkg, "utf8")).version || "")
|
|
227
|
+
} catch {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (cached && versionBelow(cached, packageVersion)) stale.push({ path: join(packages, entry), cached })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!stale.length) return
|
|
234
|
+
throw new VerificationWarning(
|
|
235
|
+
`OpenCode is running ${stale.map((s) => `${s.cached} from ${s.path}`).join(" and ")}, older than ${packageVersion}. ` +
|
|
236
|
+
`OpenCode never re-resolves an unpinned plugin: pin "opencode-goal-plugin@${packageVersion}" in opencode.json, ` +
|
|
237
|
+
"or delete that cache directory, then restart OpenCode.",
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
167
241
|
console.log()
|
|
168
242
|
|
|
169
243
|
const failed = results.filter((r) => !r.ok)
|
|
@@ -172,4 +246,7 @@ if (failed.length > 0) {
|
|
|
172
246
|
process.exit(1)
|
|
173
247
|
}
|
|
174
248
|
|
|
175
|
-
|
|
249
|
+
const warnings = results.filter((r) => r.warning).length
|
|
250
|
+
console.log(
|
|
251
|
+
`All ${results.length} checks passed${warnings ? ` with ${warnings} warning(s)` : ""}. opencode-goal-plugin is installed correctly.`,
|
|
252
|
+
)
|
package/src/goal-plugin.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|