opencode-goal-plugin 0.1.11 → 0.1.13

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,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.13 — 2026-06-11
6
+
7
+ > 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.
8
+
9
+ - **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.
10
+ - Rename `tracked_tokens_used` / `tracked_tokens_remaining` → `context_tokens_used` / `context_tokens_remaining` in continuation prompts.
11
+ - Rename `Tokens:` → `Context tokens:` in status and result displays.
12
+ - Rename `tracked token limit` / `tracked token budget` → `context token limit` / `context token budget` in all user-facing messages.
13
+ - Add regression test verifying that multi-message token tracking no longer accumulates across turns.
14
+
15
+ ## 0.1.12 — 2026-06-08
16
+
17
+ - 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.
18
+ - 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.
19
+
5
20
  ## 0.1.11 — 2026-06-04
6
21
 
7
22
  - Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model.
@@ -86,4 +101,4 @@
86
101
 
87
102
  ## 0.1.0
88
103
 
89
- - Initial experimental marker-based `/goal` plugin.
104
+ - Initial experimental marker-based `/goal` plugin.
package/CONTRIBUTING.md CHANGED
@@ -17,6 +17,7 @@ npm test
17
17
  npm run test:coverage
18
18
  npm run smoke
19
19
  npm run check
20
+ npm run smoke
20
21
  npm run pack:check
21
22
  ```
22
23
 
@@ -46,6 +47,10 @@ Before publishing or tagging a release:
46
47
  - perform at least one manual OpenCode smoke test if hook behavior changed
47
48
  - refresh compatibility notes if the tested OpenCode surface changed
48
49
 
50
+ `npm run smoke` verifies the published package entrypoint and `/goal` command hook without
51
+ invoking a model. It does not replace a manual OpenCode smoke test after hook or command
52
+ behavior changes.
53
+
49
54
  ## Pull Requests
50
55
 
51
56
  Keep pull requests focused. Include:
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 |
@@ -226,12 +226,6 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
226
226
  6. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
227
227
  7. If you changed hook payload handling or command behavior, repeat the smoke test against the exact OpenCode version and provider/backend combination you care about.
228
228
 
229
- ### Release checklist
230
-
231
- - Confirm `npm test`, `npm run test:coverage`, `npm run check`, `npm run smoke`, and `npm run pack:check` all pass.
232
- - Re-test against a real OpenCode install when touching command hooks, idle-event handling, or system-prompt transforms.
233
- - Update compatibility notes and changelog entries when behavior or tested surfaces change.
234
-
235
229
  ## Development
236
230
 
237
231
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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
+ }
@@ -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
 
@@ -679,14 +679,16 @@ function buildLimitWarning(goal) {
679
679
  warnings.push(`${Math.max(0, Math.round(remainingMs / 1000))}s remaining`)
680
680
  }
681
681
  if (remainingTokens <= goal.options.warnTokensRemaining) {
682
- warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} tracked token(s) remaining`)
682
+ warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} context token(s) remaining`)
683
683
  }
684
684
 
685
685
  return warnings.length ? ` Limits are near: ${warnings.join(", ")}.` : ""
686
686
  }
687
687
 
688
688
  function escapeGoalText(text) {
689
- return String(text).replaceAll("</goal_objective>", "<\\/goal_objective>")
689
+ // 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("</", "<\\/")
690
692
  }
691
693
 
692
694
  function buildGoalBlock(goal) {
@@ -709,8 +711,8 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) {
709
711
  "<progress_budget>",
710
712
  `auto_continues_used: ${goal.turnCount}`,
711
713
  `auto_continues_remaining: ${remainingTurns}`,
712
- `tracked_tokens_used: ${goal.totalTokens}`,
713
- `tracked_tokens_remaining: ${remainingTokens}`,
714
+ `context_tokens_used: ${goal.totalTokens}`,
715
+ `context_tokens_remaining: ${remainingTokens}`,
714
716
  `elapsed_seconds: ${elapsedSeconds}`,
715
717
  "</progress_budget>",
716
718
  "",
@@ -719,7 +721,7 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) {
719
721
  if (budgetWrapup) {
720
722
  lines.push(
721
723
  "<budget_wrapup>",
722
- "This goal is near its tracked token limit. Finish the current step if it is small and safe.",
724
+ "This goal is near its context token limit. Finish the current step if it is small and safe.",
723
725
  "Then write a concise handoff summary covering what is done, what remains, and the next concrete command or file to inspect.",
724
726
  "Do not output [goal:complete] unless the goal is actually finished and verified.",
725
727
  "After the handoff, stop.",
@@ -1028,7 +1030,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1028
1030
  pushHistory(
1029
1031
  goal,
1030
1032
  "set",
1031
- `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} tracked tokens.`,
1033
+ `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
1032
1034
  )
1033
1035
 
1034
1036
  cleanupGoal(sessionID)
@@ -1047,7 +1049,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1047
1049
  "",
1048
1050
  `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
1049
1051
  goal.options.maxDurationMs / 1000,
1050
- )}s, ${goal.options.maxTokens.toLocaleString()} tracked tokens.`,
1052
+ )}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
1051
1053
  ].join("\n"),
1052
1054
  ),
1053
1055
  ]
@@ -1070,7 +1072,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1070
1072
  const currentTokens = totalTokensForMessage(message)
1071
1073
  const previousTokens = seenTokens.get(currentMessageID) || 0
1072
1074
  if (currentTokens > previousTokens) {
1073
- goal.totalTokens += currentTokens - previousTokens
1075
+ // Track the context window size (peak input+output+reasoning),
1076
+ // not cumulative API token consumption. Each message's tokens
1077
+ // include the full conversation context, so accumulating deltas
1078
+ // across messages inflates the count by re-counting prior turns.
1079
+ // Using Math.max gives the current context size, matching what
1080
+ // OpenCode displays and making the budget check intuitive.
1081
+ goal.totalTokens = Math.max(goal.totalTokens, currentTokens)
1074
1082
  seenTokens.set(currentMessageID, currentTokens)
1075
1083
  goal.messageIDs.add(currentMessageID)
1076
1084
  changed = true
@@ -1259,7 +1267,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1259
1267
  activeGoalAfterPrompt,
1260
1268
  budgetWrapup ? "budget-wrapup" : "auto-continue",
1261
1269
  budgetWrapup
1262
- ? "Sent a final handoff request near the tracked token budget."
1270
+ ? "Sent a final handoff request near the context token budget."
1263
1271
  : `Sent auto-continue prompt ${activeGoalAfterPrompt.turnCount}/${activeGoalAfterPrompt.options.maxTurns}.`,
1264
1272
  )
1265
1273
  }