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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.1.13",
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",
@@ -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
- const hooks = await GoalPlugin({ client }, { minDelayMs: 1 })
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")
@@ -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
- return String(text).replaceAll("</", "<\\/")
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 = currentGoal(sessionID, goalID)
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 = currentGoal(sessionID, goalID)
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,