opencode-goal-plugin 0.1.12 → 0.1.14
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 +20 -2
- package/README.md +5 -4
- package/package.json +2 -2
- package/scripts/smoke-command-hook.mjs +3 -1
- package/src/goal-plugin.js +63 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.14 — 2026-06-12
|
|
6
|
+
|
|
7
|
+
- **Count cached context tokens in the budget.** `totalTokensForMessage` now includes `tokens.cache.read` / `cache.write` alongside `input + output + reasoning`. On providers with prompt caching (e.g. Anthropic) most of the conversation context arrives as cache reads with a tiny `input`, so the prior estimate undercounted the context window and the token budget / wrap-up could effectively never trigger.
|
|
8
|
+
- **Honor `/goal pause` issued mid-handler.** The post-await re-checks in the idle handler now use a new `activeGoal` helper that treats a `stopped` goal as inactive, so a pause sent while messages are being fetched or during the cooldown no longer lets one more auto-continue slip through. Adds a regression test.
|
|
9
|
+
- **Harden `escapeGoalText` against forged opening tags.** In addition to escaping closing tags, the plugin now neutralizes opening forms of its own structural tags (`<budget_wrapup>`, `<next_step>`, `<completion_audit>`, `<goal_objective>`, `<goal_continuation>`, `<progress_budget>`), closing a prompt-injection path where goal text could mimic elevated-instruction blocks. Non-structural tag-like text (e.g. `<div>`) is left untouched.
|
|
10
|
+
- **Stop the smoke test from touching real state.** `scripts/smoke-command-hook.mjs` now runs with `persistState: false`, so `npm run smoke` can no longer read or overwrite `~/.opencode-goal-plugin/state.json`.
|
|
11
|
+
- Document the `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` options in the README.
|
|
12
|
+
|
|
13
|
+
## 0.1.13 — 2026-06-11
|
|
14
|
+
|
|
15
|
+
> Fixes a significant token-tracking bug where the reported token count could be 5–10× higher than what OpenCode displays, making budgets appear exhausted far sooner than expected.
|
|
16
|
+
|
|
17
|
+
- **Fix token tracking to use context window size instead of cumulative API consumption.** Each `message.updated` event carries `input + output + reasoning` tokens where `input` already includes the full conversation context. Accumulating deltas across messages re-counted prior turns every time, inflating the total. The plugin now uses `Math.max` across all message updates so `totalTokens` reflects the peak context window size — matching what OpenCode reports.
|
|
18
|
+
- Rename `tracked_tokens_used` / `tracked_tokens_remaining` → `context_tokens_used` / `context_tokens_remaining` in continuation prompts.
|
|
19
|
+
- Rename `Tokens:` → `Context tokens:` in status and result displays.
|
|
20
|
+
- Rename `tracked token limit` / `tracked token budget` → `context token limit` / `context token budget` in all user-facing messages.
|
|
21
|
+
- Add regression test verifying that multi-message token tracking no longer accumulates across turns.
|
|
22
|
+
|
|
5
23
|
## 0.1.12 — 2026-06-08
|
|
6
24
|
|
|
7
|
-
- Harden `escapeGoalText` to escape all XML closing tags (`</` →
|
|
25
|
+
- Harden `escapeGoalText` to escape all XML closing tags (`</` → `<\\/`) instead of only `</goal_objective>`, closing a prompt-injection path where user-supplied goal text could break structural framing in the continuation message.
|
|
8
26
|
- Add unit tests for `outputTokensForMessage`, `budgetWrapupNeeded`, `getSessionID`, `stopReason`, `normalizeOptions` boundary inputs (zero, negative, NaN, null, `budgetWrapupRatio` at 0 and 1), and `escapeGoalText` covering all structural tags.
|
|
9
27
|
|
|
10
28
|
## 0.1.11 — 2026-06-04
|
|
@@ -91,4 +109,4 @@
|
|
|
91
109
|
|
|
92
110
|
## 0.1.0
|
|
93
111
|
|
|
94
|
-
- Initial experimental marker-based `/goal` plugin.
|
|
112
|
+
- Initial experimental marker-based `/goal` plugin.
|
package/README.md
CHANGED
|
@@ -110,15 +110,15 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
|
|
|
110
110
|
|---|---|
|
|
111
111
|
| Auto-continue turns | 10 |
|
|
112
112
|
| Max duration | 15 minutes |
|
|
113
|
-
|
|
|
113
|
+
| Context tokens | 200,000 |
|
|
114
114
|
| Min delay between continues | 1.5 seconds |
|
|
115
115
|
| No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) |
|
|
116
|
-
| Budget wrap-up threshold | 80% of
|
|
116
|
+
| Budget wrap-up threshold | 80% of context token budget |
|
|
117
117
|
| Auto-continue failure pause | 3 consecutive prompt failures |
|
|
118
118
|
|
|
119
119
|
**Effective turn count.** Each LLM turn on a real task typically takes 30–90 seconds. At that latency, raising `--max-minutes` is usually more useful than raising `--max-turns`. At 45 s/turn, the default 15-minute window gives roughly 15–20 turns of headroom before the turn limit becomes the binding brake.
|
|
120
120
|
|
|
121
|
-
**Token budget.** The plugin tracks `input + output + reasoning` tokens
|
|
121
|
+
**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.
|
|
122
122
|
|
|
123
123
|
**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.
|
|
124
124
|
|
|
@@ -141,7 +141,7 @@ Override any limit for a single goal:
|
|
|
141
141
|
| `--max-turns <n>` | Auto-continue turn limit |
|
|
142
142
|
| `--max-minutes <n>` | Duration limit in minutes |
|
|
143
143
|
| `--max-duration-ms <n>` | Duration limit in milliseconds |
|
|
144
|
-
| `--max-tokens <n>` |
|
|
144
|
+
| `--max-tokens <n>` | Context token limit |
|
|
145
145
|
| `--cooldown-ms <n>` | Minimum delay between continues |
|
|
146
146
|
| `--no-progress-threshold <n>` | Output token floor before pausing |
|
|
147
147
|
| `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
|
|
@@ -187,6 +187,7 @@ Additional plugin-level options:
|
|
|
187
187
|
|
|
188
188
|
- `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.
|
|
189
189
|
- `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.
|
|
190
|
+
- `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` — thresholds at which the auto-continue prompt appends a "limits are near" warning (default `3` turns, `60000` ms, `25000` context tokens). Lower them to warn closer to the limit, or raise them to warn earlier.
|
|
190
191
|
- `persistState` — whether to persist active goals and recent goal results to disk.
|
|
191
192
|
- `stateFilePath` — where the persisted state JSON is written. Useful if you want per-project or ephemeral storage.
|
|
192
193
|
- `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Session-scoped /goal workflow for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -47,4 +47,4 @@
|
|
|
47
47
|
"author": {
|
|
48
48
|
"name": "willytop8"
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
|
@@ -23,7 +23,9 @@ const client = {
|
|
|
23
23
|
assert.equal(pluginModule.id, "opencode-goal-plugin")
|
|
24
24
|
assert.equal(pluginModule.server, GoalPlugin)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// persistState:false keeps the smoke test from reading or overwriting the
|
|
27
|
+
// user's real ~/.opencode-goal-plugin/state.json.
|
|
28
|
+
const hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
|
|
27
29
|
assert.equal(typeof hooks["command.execute.before"], "function")
|
|
28
30
|
assert.equal(typeof hooks.event, "function")
|
|
29
31
|
assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
|
package/src/goal-plugin.js
CHANGED
|
@@ -142,7 +142,7 @@ function formatStatus(goal) {
|
|
|
142
142
|
const lines = [
|
|
143
143
|
`Active goal: ${goal.condition}`,
|
|
144
144
|
`Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
|
|
145
|
-
`
|
|
145
|
+
`Context tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`,
|
|
146
146
|
`Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
|
|
147
147
|
`Last progress: ${lastProgress}`,
|
|
148
148
|
`No-progress turns: ${goal.noProgressTurns}`,
|
|
@@ -168,7 +168,7 @@ function formatGoalResult(result) {
|
|
|
168
168
|
`Last goal: ${result.condition}`,
|
|
169
169
|
`State: ${result.state}`,
|
|
170
170
|
`Auto-continues sent: ${result.turnCount}`,
|
|
171
|
-
`
|
|
171
|
+
`Context tokens: ${result.totalTokens.toLocaleString()}`,
|
|
172
172
|
`Elapsed: ${elapsed}s`,
|
|
173
173
|
`Last checkpoint: ${lastCheckpoint}`,
|
|
174
174
|
`Last status: ${result.lastStatus || "No status recorded."}`,
|
|
@@ -198,7 +198,7 @@ function stopReason(goal) {
|
|
|
198
198
|
if (Date.now() - goal.startedAt >= goal.options.maxDurationMs) {
|
|
199
199
|
return `max duration reached (${Math.round(goal.options.maxDurationMs / 1000)}s)`
|
|
200
200
|
}
|
|
201
|
-
if (goal.totalTokens >= goal.options.maxTokens) return `max tokens reached (${goal.options.maxTokens})`
|
|
201
|
+
if (goal.totalTokens >= goal.options.maxTokens) return `max context tokens reached (${goal.options.maxTokens.toLocaleString()})`
|
|
202
202
|
return null
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -285,6 +285,16 @@ function currentGoal(sessionID, goalID) {
|
|
|
285
285
|
return goal
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
// Like currentGoal, but also returns null if the goal was stopped (paused,
|
|
289
|
+
// cleared-and-replaced, blocked) while an async step was in flight. Used at the
|
|
290
|
+
// post-await re-checks so a `/goal pause` issued during messages-fetch or the
|
|
291
|
+
// cooldown sleep actually prevents the next auto-continue from firing.
|
|
292
|
+
function activeGoal(sessionID, goalID) {
|
|
293
|
+
const goal = currentGoal(sessionID, goalID)
|
|
294
|
+
if (!goal || goal.stopped) return null
|
|
295
|
+
return goal
|
|
296
|
+
}
|
|
297
|
+
|
|
288
298
|
function toPositiveInteger(value, fallback) {
|
|
289
299
|
const parsed = Number(value)
|
|
290
300
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
@@ -679,16 +689,34 @@ function buildLimitWarning(goal) {
|
|
|
679
689
|
warnings.push(`${Math.max(0, Math.round(remainingMs / 1000))}s remaining`)
|
|
680
690
|
}
|
|
681
691
|
if (remainingTokens <= goal.options.warnTokensRemaining) {
|
|
682
|
-
warnings.push(`${Math.max(0, remainingTokens).toLocaleString()}
|
|
692
|
+
warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} context token(s) remaining`)
|
|
683
693
|
}
|
|
684
694
|
|
|
685
695
|
return warnings.length ? ` Limits are near: ${warnings.join(", ")}.` : ""
|
|
686
696
|
}
|
|
687
697
|
|
|
698
|
+
// Tag names the plugin uses to frame its own instructions. Goal text must not
|
|
699
|
+
// be able to forge either an opening or a closing form of any of these.
|
|
700
|
+
const STRUCTURAL_TAGS = [
|
|
701
|
+
"goal_continuation",
|
|
702
|
+
"goal_objective",
|
|
703
|
+
"progress_budget",
|
|
704
|
+
"budget_wrapup",
|
|
705
|
+
"next_step",
|
|
706
|
+
"completion_audit",
|
|
707
|
+
]
|
|
708
|
+
const STRUCTURAL_OPEN_TAG_RE = new RegExp(`<(${STRUCTURAL_TAGS.join("|")})\\b`, "gi")
|
|
709
|
+
|
|
688
710
|
function escapeGoalText(text) {
|
|
689
711
|
// Escape every XML closing tag so user-supplied goal text cannot break the
|
|
690
|
-
// structural framing used in buildGoalBlock and buildContinueMessage
|
|
691
|
-
|
|
712
|
+
// structural framing used in buildGoalBlock and buildContinueMessage...
|
|
713
|
+
let escaped = String(text).replaceAll("</", "<\\/")
|
|
714
|
+
// ...and neutralize opening forms of the plugin's own structural tags so goal
|
|
715
|
+
// text cannot inject a forged block (e.g. <budget_wrapup>, <next_step>) that
|
|
716
|
+
// mimics elevated instructions. Closing forms are already broken above, so
|
|
717
|
+
// this regex only matches genuine `<tag` openings.
|
|
718
|
+
escaped = escaped.replace(STRUCTURAL_OPEN_TAG_RE, "<\\$1")
|
|
719
|
+
return escaped
|
|
692
720
|
}
|
|
693
721
|
|
|
694
722
|
function buildGoalBlock(goal) {
|
|
@@ -711,8 +739,8 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) {
|
|
|
711
739
|
"<progress_budget>",
|
|
712
740
|
`auto_continues_used: ${goal.turnCount}`,
|
|
713
741
|
`auto_continues_remaining: ${remainingTurns}`,
|
|
714
|
-
`
|
|
715
|
-
`
|
|
742
|
+
`context_tokens_used: ${goal.totalTokens}`,
|
|
743
|
+
`context_tokens_remaining: ${remainingTokens}`,
|
|
716
744
|
`elapsed_seconds: ${elapsedSeconds}`,
|
|
717
745
|
"</progress_budget>",
|
|
718
746
|
"",
|
|
@@ -721,7 +749,7 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) {
|
|
|
721
749
|
if (budgetWrapup) {
|
|
722
750
|
lines.push(
|
|
723
751
|
"<budget_wrapup>",
|
|
724
|
-
"This goal is near its
|
|
752
|
+
"This goal is near its context token limit. Finish the current step if it is small and safe.",
|
|
725
753
|
"Then write a concise handoff summary covering what is done, what remains, and the next concrete command or file to inspect.",
|
|
726
754
|
"Do not output [goal:complete] unless the goal is actually finished and verified.",
|
|
727
755
|
"After the handoff, stop.",
|
|
@@ -798,12 +826,23 @@ function messageTokens(message) {
|
|
|
798
826
|
: {}
|
|
799
827
|
}
|
|
800
828
|
|
|
829
|
+
function cacheTokensForMessage(tokens) {
|
|
830
|
+
// OpenCode reports cached context separately as `cache: { read, write }`.
|
|
831
|
+
// On cache-heavy providers (e.g. Anthropic prompt caching) most of the
|
|
832
|
+
// conversation context arrives as `cache.read` with a small `input`, so the
|
|
833
|
+
// cache fields must be counted toward the context-window estimate or the
|
|
834
|
+
// token budget is undercounted by an order of magnitude.
|
|
835
|
+
const cache = isPlainObject(tokens.cache) ? tokens.cache : {}
|
|
836
|
+
return toNonNegativeInteger(cache.read) + toNonNegativeInteger(cache.write)
|
|
837
|
+
}
|
|
838
|
+
|
|
801
839
|
function totalTokensForMessage(message) {
|
|
802
840
|
const tokens = messageTokens(message)
|
|
803
841
|
return (
|
|
804
842
|
toNonNegativeInteger(tokens.input) +
|
|
805
843
|
toNonNegativeInteger(tokens.output) +
|
|
806
|
-
toNonNegativeInteger(tokens.reasoning)
|
|
844
|
+
toNonNegativeInteger(tokens.reasoning) +
|
|
845
|
+
cacheTokensForMessage(tokens)
|
|
807
846
|
)
|
|
808
847
|
}
|
|
809
848
|
|
|
@@ -1030,7 +1069,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1030
1069
|
pushHistory(
|
|
1031
1070
|
goal,
|
|
1032
1071
|
"set",
|
|
1033
|
-
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()}
|
|
1072
|
+
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
|
|
1034
1073
|
)
|
|
1035
1074
|
|
|
1036
1075
|
cleanupGoal(sessionID)
|
|
@@ -1049,7 +1088,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1049
1088
|
"",
|
|
1050
1089
|
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
1051
1090
|
goal.options.maxDurationMs / 1000,
|
|
1052
|
-
)}s, ${goal.options.maxTokens.toLocaleString()}
|
|
1091
|
+
)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
|
|
1053
1092
|
].join("\n"),
|
|
1054
1093
|
),
|
|
1055
1094
|
]
|
|
@@ -1072,7 +1111,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1072
1111
|
const currentTokens = totalTokensForMessage(message)
|
|
1073
1112
|
const previousTokens = seenTokens.get(currentMessageID) || 0
|
|
1074
1113
|
if (currentTokens > previousTokens) {
|
|
1075
|
-
|
|
1114
|
+
// Track the context window size (peak input+output+reasoning),
|
|
1115
|
+
// not cumulative API token consumption. Each message's tokens
|
|
1116
|
+
// include the full conversation context, so accumulating deltas
|
|
1117
|
+
// across messages inflates the count by re-counting prior turns.
|
|
1118
|
+
// Using Math.max gives the current context size, matching what
|
|
1119
|
+
// OpenCode displays and making the budget check intuitive.
|
|
1120
|
+
goal.totalTokens = Math.max(goal.totalTokens, currentTokens)
|
|
1076
1121
|
seenTokens.set(currentMessageID, currentTokens)
|
|
1077
1122
|
goal.messageIDs.add(currentMessageID)
|
|
1078
1123
|
changed = true
|
|
@@ -1106,7 +1151,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1106
1151
|
path: { id: sessionID },
|
|
1107
1152
|
query: { limit: goal.options.maxRecentMessages },
|
|
1108
1153
|
})
|
|
1109
|
-
const activeGoalAfterMessages =
|
|
1154
|
+
const activeGoalAfterMessages = activeGoal(sessionID, goalID)
|
|
1110
1155
|
if (!activeGoalAfterMessages) return
|
|
1111
1156
|
|
|
1112
1157
|
const latestAssistant = findLatestAssistantMessage(messages.data)
|
|
@@ -1211,7 +1256,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1211
1256
|
await sleep(activeGoalAfterMessages.options.minDelayMs - elapsedSinceLastContinue)
|
|
1212
1257
|
}
|
|
1213
1258
|
|
|
1214
|
-
const activeGoalBeforePrompt =
|
|
1259
|
+
const activeGoalBeforePrompt = activeGoal(sessionID, goalID)
|
|
1215
1260
|
if (!activeGoalBeforePrompt) return
|
|
1216
1261
|
|
|
1217
1262
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
@@ -1261,7 +1306,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1261
1306
|
activeGoalAfterPrompt,
|
|
1262
1307
|
budgetWrapup ? "budget-wrapup" : "auto-continue",
|
|
1263
1308
|
budgetWrapup
|
|
1264
|
-
? "Sent a final handoff request near the
|
|
1309
|
+
? "Sent a final handoff request near the context token budget."
|
|
1265
1310
|
: `Sent auto-continue prompt ${activeGoalAfterPrompt.turnCount}/${activeGoalAfterPrompt.options.maxTurns}.`,
|
|
1266
1311
|
)
|
|
1267
1312
|
}
|
|
@@ -1326,6 +1371,7 @@ export default {
|
|
|
1326
1371
|
}
|
|
1327
1372
|
|
|
1328
1373
|
export const testInternals = {
|
|
1374
|
+
activeGoal,
|
|
1329
1375
|
buildLimitWarning,
|
|
1330
1376
|
buildContinueMessage,
|
|
1331
1377
|
buildGoalBlock,
|
|
@@ -1333,6 +1379,7 @@ export const testInternals = {
|
|
|
1333
1379
|
cleanupGoal,
|
|
1334
1380
|
currentGoal,
|
|
1335
1381
|
escapeGoalText,
|
|
1382
|
+
totalTokensForMessage,
|
|
1336
1383
|
extractBlockedReason,
|
|
1337
1384
|
findLatestAssistantMessage,
|
|
1338
1385
|
formatArgumentErrors,
|