opencode-goal-plugin 0.1.13 → 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 +8 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/scripts/smoke-command-hook.mjs +3 -1
- package/src/goal-plugin.js +46 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## 0.1.13 — 2026-06-11
|
|
6
14
|
|
|
7
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.
|
package/README.md
CHANGED
|
@@ -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
|
@@ -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
|
@@ -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
|
|
@@ -685,10 +695,28 @@ function buildLimitWarning(goal) {
|
|
|
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) {
|
|
@@ -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
|
|
|
@@ -1112,7 +1151,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1112
1151
|
path: { id: sessionID },
|
|
1113
1152
|
query: { limit: goal.options.maxRecentMessages },
|
|
1114
1153
|
})
|
|
1115
|
-
const activeGoalAfterMessages =
|
|
1154
|
+
const activeGoalAfterMessages = activeGoal(sessionID, goalID)
|
|
1116
1155
|
if (!activeGoalAfterMessages) return
|
|
1117
1156
|
|
|
1118
1157
|
const latestAssistant = findLatestAssistantMessage(messages.data)
|
|
@@ -1217,7 +1256,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1217
1256
|
await sleep(activeGoalAfterMessages.options.minDelayMs - elapsedSinceLastContinue)
|
|
1218
1257
|
}
|
|
1219
1258
|
|
|
1220
|
-
const activeGoalBeforePrompt =
|
|
1259
|
+
const activeGoalBeforePrompt = activeGoal(sessionID, goalID)
|
|
1221
1260
|
if (!activeGoalBeforePrompt) return
|
|
1222
1261
|
|
|
1223
1262
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
@@ -1332,6 +1371,7 @@ export default {
|
|
|
1332
1371
|
}
|
|
1333
1372
|
|
|
1334
1373
|
export const testInternals = {
|
|
1374
|
+
activeGoal,
|
|
1335
1375
|
buildLimitWarning,
|
|
1336
1376
|
buildContinueMessage,
|
|
1337
1377
|
buildGoalBlock,
|
|
@@ -1339,6 +1379,7 @@ export const testInternals = {
|
|
|
1339
1379
|
cleanupGoal,
|
|
1340
1380
|
currentGoal,
|
|
1341
1381
|
escapeGoalText,
|
|
1382
|
+
totalTokensForMessage,
|
|
1342
1383
|
extractBlockedReason,
|
|
1343
1384
|
findLatestAssistantMessage,
|
|
1344
1385
|
formatArgumentErrors,
|