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 +14 -0
- package/README.md +10 -0
- package/package.json +2 -2
- package/scripts/smoke-command-hook.mjs +3 -1
- package/src/goal-plugin.js +129 -5
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.
|
|
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
|
-
|
|
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) {
|
|
@@ -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 =
|
|
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 =
|
|
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,
|