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 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 (`</` → `<\/`) instead of only `</goal_objective>`, closing a prompt-injection path where user-supplied goal text could break structural framing in the continuation message.
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
- | Tracked tokens | 200,000 |
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 tracked token budget |
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 across all session messages. In high-context sessions (large codebases, long conversation history), input overhead per turn can be substantial and the budget may be exhausted before the turn limit is reached. Treat it as a safety brake, not precise billing accounting.
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>` | Tracked token limit |
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.12",
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
- 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")
@@ -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
- `Tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`,
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
- `Tokens: ${result.totalTokens.toLocaleString()}`,
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()} tracked token(s) remaining`)
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
- 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) {
@@ -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
- `tracked_tokens_used: ${goal.totalTokens}`,
715
- `tracked_tokens_remaining: ${remainingTokens}`,
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 tracked token limit. Finish the current step if it is small and safe.",
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()} tracked tokens.`,
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()} tracked tokens.`,
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
- goal.totalTokens += currentTokens - previousTokens
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 = currentGoal(sessionID, goalID)
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 = currentGoal(sessionID, goalID)
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 tracked token budget."
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,