opencode-goal-plugin 0.1.13 → 0.2.0

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,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 — 2026-06-14
6
+
7
+ - **Add `/goal edit <new objective>`.** Revise the active goal's objective in place while preserving its turn/token/time budget and lifecycle history. Any pause/blocked state is cleared and `noProgressTurns` resets so the revised goal can continue; a goal already at a hard limit re-pauses on the next idle (use `/goal resume` for a fresh budget window). Ported from prevalentWare/opencode-goal-plugin's `update_goal_objective` tool, adapted to the marker-based command model.
8
+ - **Preserve the goal across session compaction.** A new `experimental.session.compacting` hook injects the goal objective, status, budget usage, elapsed time, and latest checkpoint into the compaction context so a compaction no longer drops the goal thread mid-run. Ported from prevalentWare/opencode-goal-plugin's `compactionContext` injection.
9
+ - **Disable generic post-compaction auto-continue while a goal is active.** A new `experimental.compaction.autocontinue` hook sets `enabled = false` whenever an active (non-stopped) goal is present, so OpenCode's native post-compaction continuation does not race the plugin's own idle-triggered continuation. Paused/stopped goals leave the native behavior untouched. Ported from prevalentWare/opencode-goal-plugin.
10
+
11
+ ## 0.1.14 — 2026-06-12
12
+
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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`.
17
+ - Document the `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` options in the README.
18
+
5
19
  ## 0.1.13 — 2026-06-11
6
20
 
7
21
  > 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
@@ -70,6 +70,14 @@ Resume a paused or stopped goal:
70
70
  /goal resume
71
71
  ```
72
72
 
73
+ Edit the active goal's objective without losing its budget or history:
74
+
75
+ ```
76
+ /goal edit fix the failing tests and also update the docs
77
+ ```
78
+
79
+ `/goal edit <new objective>` revises the goal in place: the turn, token, and time budget plus the lifecycle history are preserved, and any pause/blocked state is cleared so the revised goal can continue. A goal that already hit a hard limit will re-pause on the next idle — run `/goal resume` for a fresh budget window.
80
+
73
81
  Pause without clearing the active goal:
74
82
 
75
83
  ```
@@ -89,6 +97,7 @@ Clear the active goal:
89
97
  1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn.
90
98
  2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done.
91
99
  3. The plugin stops auto-continuing when the assistant ends a response with `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached.
100
+ 4. If OpenCode compacts the session, the plugin injects the goal objective, budget usage, and latest checkpoint into the compaction context so the goal survives the compaction and the assistant keeps the thread. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation.
92
101
 
93
102
  ## Completion markers
94
103
 
@@ -187,6 +196,7 @@ Additional plugin-level options:
187
196
 
188
197
  - `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
198
  - `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.
199
+ - `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
200
  - `persistState` — whether to persist active goals and recent goal results to disk.
191
201
  - `stateFilePath` — where the persisted state JSON is written. Useful if you want per-project or ephemeral storage.
192
202
  - `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.2.0",
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")
@@ -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) {
@@ -755,6 +783,24 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) {
755
783
  return lines.filter(Boolean).join("\n")
756
784
  }
757
785
 
786
+ function buildCompactionContext(goal) {
787
+ // Preserve the active goal across an OpenCode session compaction. Without
788
+ // this, a compaction can drop the goal objective and budget state from the
789
+ // working context, so the assistant loses the thread mid-run even though the
790
+ // plugin still re-injects via system.transform afterward.
791
+ const elapsedSeconds = Math.round((Date.now() - goal.startedAt) / 1000)
792
+ return [
793
+ "An OpenCode goal is active for this session. Preserve it across compaction.",
794
+ buildGoalBlock(goal),
795
+ `Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
796
+ `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
797
+ goal.lastCheckpoint ? `Latest checkpoint: ${goal.lastCheckpoint.summary}` : null,
798
+ "After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] only when fully satisfied, or [goal:blocked] only if user input is required.",
799
+ ]
800
+ .filter(Boolean)
801
+ .join("\n")
802
+ }
803
+
758
804
  function extractBlockedReason(text) {
759
805
  const lines = text.trimEnd().split("\n")
760
806
  const markerIndex = lines.findIndex((line) => {
@@ -798,12 +844,23 @@ function messageTokens(message) {
798
844
  : {}
799
845
  }
800
846
 
847
+ function cacheTokensForMessage(tokens) {
848
+ // OpenCode reports cached context separately as `cache: { read, write }`.
849
+ // On cache-heavy providers (e.g. Anthropic prompt caching) most of the
850
+ // conversation context arrives as `cache.read` with a small `input`, so the
851
+ // cache fields must be counted toward the context-window estimate or the
852
+ // token budget is undercounted by an order of magnitude.
853
+ const cache = isPlainObject(tokens.cache) ? tokens.cache : {}
854
+ return toNonNegativeInteger(cache.read) + toNonNegativeInteger(cache.write)
855
+ }
856
+
801
857
  function totalTokensForMessage(message) {
802
858
  const tokens = messageTokens(message)
803
859
  return (
804
860
  toNonNegativeInteger(tokens.input) +
805
861
  toNonNegativeInteger(tokens.output) +
806
- toNonNegativeInteger(tokens.reasoning)
862
+ toNonNegativeInteger(tokens.reasoning) +
863
+ cacheTokensForMessage(tokens)
807
864
  )
808
865
  }
809
866
 
@@ -992,6 +1049,47 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
992
1049
  return
993
1050
  }
994
1051
 
1052
+ if (args === "edit" || args.toLowerCase().startsWith("edit ")) {
1053
+ const goal = goalStates.get(sessionID)
1054
+ if (!goal) {
1055
+ output.parts = [
1056
+ makeTextPart("No active goal to edit. Set one with `/goal <condition>`."),
1057
+ ]
1058
+ return
1059
+ }
1060
+ const newObjective = stripWrappingQuotes(args.slice("edit".length).trim())
1061
+ if (!newObjective) {
1062
+ output.parts = [
1063
+ makeTextPart("No new objective provided. Use `/goal edit <new objective>`."),
1064
+ ]
1065
+ return
1066
+ }
1067
+
1068
+ goal.condition = newObjective
1069
+ // Editing the objective revises the goal in place: keep the turn,
1070
+ // token, and time budget plus history, but clear soft-stop state so the
1071
+ // revised goal can continue. A goal that hit a hard limit will re-pause
1072
+ // on the next idle (use /goal resume for a fresh budget window).
1073
+ goal.stopped = false
1074
+ goal.stopReason = ""
1075
+ goal.blockedReason = ""
1076
+ goal.budgetWrapupSent = false
1077
+ goal.noProgressTurns = 0
1078
+ goal.lastStatus = "Goal objective updated."
1079
+ pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
1080
+ await persist()
1081
+ output.parts = [
1082
+ makeTextPart(
1083
+ [
1084
+ `Goal objective updated: ${goal.condition}`,
1085
+ "",
1086
+ "Budgets and history are preserved. Run `/goal resume` for a fresh budget window, or `/goal status` to review.",
1087
+ ].join("\n"),
1088
+ ),
1089
+ ]
1090
+ return
1091
+ }
1092
+
995
1093
  const parsed = parseGoalArguments(args, defaultGoalOptions)
996
1094
  if (parsed.errors.length > 0) {
997
1095
  output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))]
@@ -1112,7 +1210,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1112
1210
  path: { id: sessionID },
1113
1211
  query: { limit: goal.options.maxRecentMessages },
1114
1212
  })
1115
- const activeGoalAfterMessages = currentGoal(sessionID, goalID)
1213
+ const activeGoalAfterMessages = activeGoal(sessionID, goalID)
1116
1214
  if (!activeGoalAfterMessages) return
1117
1215
 
1118
1216
  const latestAssistant = findLatestAssistantMessage(messages.data)
@@ -1217,7 +1315,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1217
1315
  await sleep(activeGoalAfterMessages.options.minDelayMs - elapsedSinceLastContinue)
1218
1316
  }
1219
1317
 
1220
- const activeGoalBeforePrompt = currentGoal(sessionID, goalID)
1318
+ const activeGoalBeforePrompt = activeGoal(sessionID, goalID)
1221
1319
  if (!activeGoalBeforePrompt) return
1222
1320
 
1223
1321
  const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
@@ -1323,6 +1421,29 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1323
1421
  }
1324
1422
  output.system = systemBlocks
1325
1423
  },
1424
+
1425
+ "experimental.session.compacting": async (input, output) => {
1426
+ if (!input?.sessionID || !output) return
1427
+ const goal = goalStates.get(input.sessionID)
1428
+ if (!goal) return
1429
+ const context = buildCompactionContext(goal)
1430
+ if (Array.isArray(output.context)) {
1431
+ output.context.push(context)
1432
+ } else {
1433
+ output.context = [context]
1434
+ }
1435
+ },
1436
+
1437
+ "experimental.compaction.autocontinue": async (input, output) => {
1438
+ // When a goal is active the plugin drives its own idle-triggered
1439
+ // continuation, so disable OpenCode's generic post-compaction
1440
+ // auto-continue to avoid two continuations racing after a compaction.
1441
+ // Paused/stopped goals leave the native behavior untouched.
1442
+ if (!input?.sessionID || !output) return
1443
+ const goal = goalStates.get(input.sessionID)
1444
+ if (!goal || goal.stopped) return
1445
+ output.enabled = false
1446
+ },
1326
1447
  }
1327
1448
  }
1328
1449
 
@@ -1332,13 +1453,16 @@ export default {
1332
1453
  }
1333
1454
 
1334
1455
  export const testInternals = {
1456
+ activeGoal,
1335
1457
  buildLimitWarning,
1458
+ buildCompactionContext,
1336
1459
  buildContinueMessage,
1337
1460
  buildGoalBlock,
1338
1461
  budgetWrapupNeeded,
1339
1462
  cleanupGoal,
1340
1463
  currentGoal,
1341
1464
  escapeGoalText,
1465
+ totalTokensForMessage,
1342
1466
  extractBlockedReason,
1343
1467
  findLatestAssistantMessage,
1344
1468
  formatArgumentErrors,