opencode-goal-plugin 0.3.0 → 0.4.1
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 +15 -2
- package/README.md +17 -0
- package/package.json +9 -1
- package/src/goal-plugin.js +309 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.1 — 2026-06-29
|
|
6
|
+
|
|
7
|
+
### Bug fixes
|
|
8
|
+
|
|
9
|
+
- **Fix provider prefix cache invalidation caused by volatile limit warnings in system prompt (#14).** `experimental.chat.system.transform` was calling `buildLimitWarning`, which appends a string containing `Date.now()`-derived `remainingMs` and a per-turn `remainingTokens` counter. Once any warning threshold was crossed (default: 25 000 tokens remaining, ≤ 3 turns left, ≤ 60 s left), the system prompt changed on every provider request — including tool-call sub-requests mid-turn — invalidating the prefix cache from byte 0 each time. On a 200 k-context thinking model consuming 20–30 k reasoning tokens/turn, this triggered O(turns × tool_calls × context_size) cache misses instead of O(1), causing the ~$12/8 min cost spike reported in issue #13. Fix: `buildLimitWarning` is removed from `system.transform`; the system prompt is now byte-stable for the full lifetime of a goal. Limit warnings continue to reach the model on every continuation turn via `buildContinueMessage`, which already included them.
|
|
10
|
+
- **Cap consecutive format-validation re-prompts (#15).** A model that repeatedly omitted `[goal:evidence]` on a `[goal:complete]` marker, or omitted a concrete blocker on `[goal:blocked]`, was re-prompted indefinitely: the existing `promptFailures` counter only incremented on network/protocol errors. Added a separate `formatFailures` counter that increments on each `completionUnverified` or `blockerUnstated` re-prompt and resets on a valid response. After `maxPromptFailures` consecutive format failures the goal pauses with stop reason `format validation failures` and a descriptive status message; `/goal resume` retries.
|
|
11
|
+
- **Exclude tool-calling turns from the noProgress stall detector (#16).** `lowOutputLooksStalled` could fire on a reasoning-heavy model doing a pure tool call (small prose output, reasoning tokens only): `latestText` is empty and `latestOutputTokens` is below the 50-token threshold, matching the stall condition. Added `!latestHasToolCall` to `lowOutputLooksStalled` so a turn that invoked any tool is never counted as stalled regardless of prose output. `latestHasToolCall` is now hoisted above both the `noProgress` and `noToolCall` blocks so both gates share the same computation.
|
|
12
|
+
|
|
13
|
+
## 0.4.0 — 2026-06-21
|
|
14
|
+
|
|
15
|
+
- **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. Implements megalist items 7.1 and 7.2. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_
|
|
16
|
+
- **Fix a goal-registry leak when resuming.** `resetGoalBudget` rotates a goal's `goalId`, but the multi-goal registry is keyed by `goalId`, so resuming and then clearing/replacing left a stale entry behind (visible in `/goal list` and persisted). Both the `/goal resume` command path and the agent `update_goal {status:"resumed"}` path now re-key the registry to the new id (the focused pointer holds the same object). Regression test added for the command path.
|
|
17
|
+
|
|
5
18
|
## 0.3.0 — 2026-06-14
|
|
6
19
|
|
|
7
|
-
> A large feature release. Stronger completion integrity (evidence gate, optional auditor, visible audit messages), durable lifecycle ledger with state reconstruction, multiple goals per session with focus and ordered sisyphus sequences, richer goal schema, more auto-continue guardrails, project-local state with migration,
|
|
20
|
+
> A large feature release. Stronger completion integrity (evidence gate, optional auditor, visible audit messages), durable lifecycle ledger with state reconstruction, multiple goals per session with focus and ordered sisyphus sequences, richer goal schema, more auto-continue guardrails, project-local state with migration, a deterministic compaction summary, and npm Trusted Publishing CI. All changes are additive and backward-compatible; older state files load unchanged.
|
|
8
21
|
|
|
9
22
|
### Completion integrity & audit
|
|
10
23
|
|
|
@@ -36,7 +49,7 @@
|
|
|
36
49
|
### Storage, tools & packaging
|
|
37
50
|
|
|
38
51
|
- **Default goal state to a project-local path, with an env override and migration fallbacks.** State resolves as `stateFilePath` option → `OPENCODE_GOAL_STATE_PATH` env var → project-local `<cwd>/.opencode/goals/state.json` (previously `~/.opencode-goal-plugin/state.json`). When the default path is empty, the plugin migrates forward on first load from the legacy home path and the XDG path, then writes project-local. Explicit option/env paths are literal with no fallback; a present-but-corrupt primary is preserved. New `resolveStateFilePath` / `xdgStateFilePath` / `legacyStateFilePaths` helpers. Home-based fallback paths resolve from an injectable `env.HOME` (falling back to `os.homedir()`), making path resolution deterministic across platforms — `os.homedir()` ignores `$HOME` on macOS. Implements megalist items 6.1 and 6.2.
|
|
39
|
-
- **
|
|
52
|
+
- _**Correction (2026-06-21):** an earlier version of this entry claimed agent-facing goal tools shipped in 0.3.0. They did not — the work was on an unmerged branch (`wr/agent-tools`) and was never included in the 0.3.0 release. The feature now actually ships; see the **Unreleased** section above. Megalist items 7.1 and 7.2._
|
|
40
53
|
- **Add a `Publish` GitHub Actions workflow (`.github/workflows/publish.yml`) for npm Trusted Publishing (OIDC).** On a push to `main` it runs the full check matrix on Node 18/20/22, then publishes via OIDC with no stored `NPM_TOKEN`, using a publish-on-version-change model (only publishes when `package.json`'s version is new). The publish job requires `id-token: write` and is gated behind a `release` environment. First run still requires a human to publish an initial version and configure the npm Trusted Publisher. Implements megalist item 9.1.
|
|
41
54
|
|
|
42
55
|
## 0.2.0 — 2026-06-14
|
package/README.md
CHANGED
|
@@ -256,11 +256,28 @@ Additional plugin-level options:
|
|
|
256
256
|
- `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.
|
|
257
257
|
- `commandName` — the slash command the plugin owns (default `goal`). Set it to e.g. `objective` to drive the workflow with `/objective` instead of `/goal`; a leading slash is tolerated. Remember to register the matching command name in your OpenCode `command` config. User-facing hints (`/goal status`, `/goal resume`, …) follow the configured name.
|
|
258
258
|
- `registerCommand` — whether the plugin installs its `command.execute.before` hook at all (default `true`). Set it to `false` if you only want the auto-continue/persistence behavior driven programmatically and don't want the plugin to own a slash command.
|
|
259
|
+
- `registerTools` — whether the plugin registers the agent-facing goal tools (default `true`). Requires the optional `@opencode-ai/plugin` peer dependency to be present; when it is absent, tool registration is skipped and the command/event hooks still work. Set to `false` to omit the programmatic tool surface entirely. See [Agent tools](#agent-tools-optional).
|
|
259
260
|
- `persistState` — whether to persist active goals and recent goal results to disk.
|
|
260
261
|
- `stateFilePath` — where the persisted state JSON is written. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default is `<cwd>/.opencode/goals/state.json` (see the persistence section above), and `OPENCODE_GOAL_STATE_PATH` can override it without editing config.
|
|
261
262
|
- `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
|
|
262
263
|
- `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
|
|
263
264
|
|
|
265
|
+
## Agent tools (optional)
|
|
266
|
+
|
|
267
|
+
In addition to the `/goal` command, the plugin can expose the same workflow to the model as callable tools, so the agent can inspect and manage the goal itself. This requires the optional `@opencode-ai/plugin` peer dependency (it provides the `tool` helper and schema). When that package isn't installed the tools are simply not registered and everything else keeps working. Disable them explicitly with `registerTools: false`.
|
|
268
|
+
|
|
269
|
+
Registered tools:
|
|
270
|
+
|
|
271
|
+
- `get_goal` — current goal status (objective, budget usage, latest checkpoint).
|
|
272
|
+
- `get_goal_history` — lifecycle history and latest checkpoint.
|
|
273
|
+
- `set_goal` — set/replace the session goal. Its description constrains the agent to call it **only when the user explicitly asks** to set a goal. Accepts `objective` plus optional `maxTurns`, `maxTokens`, `maxDurationMs`, `successCriteria`, `constraints`, and `mode`.
|
|
274
|
+
- `update_goal` — revise the `objective` and/or set `status` to `complete` / `blocked` / `paused` / `resumed` (with `evidence` for complete or `blocker` for blocked).
|
|
275
|
+
- `clear_goal` — clear the current goal and discard its saved status.
|
|
276
|
+
|
|
277
|
+
These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered (sisyphus) sequence auto-promotes the next.
|
|
278
|
+
|
|
279
|
+
> Integration note: the tool execute-context shape (`ctx.sessionID`) and the `tool.schema` surface follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but the live registration should be confirmed against a real OpenCode run (see the smoke-test checklist).
|
|
280
|
+
|
|
264
281
|
## Audit messages
|
|
265
282
|
|
|
266
283
|
When the assistant marks a goal complete or blocked, the plugin announces the audit instead of doing it silently: an audit-start message ("Auditing goal completion…") and an audit-result message ("completion accepted — goal archived" / "paused as blocked — …"). By default these are delivered through OpenCode's structured log (`client.app.log`, visible to the user). Provide an `auditMessenger(sessionID, text)` plugin option to route them elsewhere (for example into the live conversation once a suitable message API is available), or set `auditMessages: false` to disable them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Session-scoped /goal workflow for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
"agent",
|
|
33
33
|
"goal"
|
|
34
34
|
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@opencode-ai/plugin": ">=1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@opencode-ai/plugin": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
35
43
|
"license": "MIT",
|
|
36
44
|
"engines": {
|
|
37
45
|
"node": ">=18"
|
package/src/goal-plugin.js
CHANGED
|
@@ -520,6 +520,7 @@ function resetGoalBudget(goal) {
|
|
|
520
520
|
goal.budgetWrapupSent = false
|
|
521
521
|
goal.messageIDs = new Set()
|
|
522
522
|
goal.promptFailures = 0
|
|
523
|
+
goal.formatFailures = 0
|
|
523
524
|
goal.lastAssistantMessageID = ""
|
|
524
525
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
525
526
|
}
|
|
@@ -1583,6 +1584,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
1583
1584
|
stopped: false,
|
|
1584
1585
|
stopReason: "",
|
|
1585
1586
|
promptFailures: 0,
|
|
1587
|
+
formatFailures: 0,
|
|
1586
1588
|
messageIDs: new Set(),
|
|
1587
1589
|
history: [],
|
|
1588
1590
|
checkpoints: [],
|
|
@@ -1590,6 +1592,240 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
1590
1592
|
}
|
|
1591
1593
|
}
|
|
1592
1594
|
|
|
1595
|
+
const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
|
|
1596
|
+
|
|
1597
|
+
// Programmatic equivalents of the /goal command, exposed to the agent as tools
|
|
1598
|
+
// (megalist items 7.1 / 7.2). Each handler operates on a session id and mutates
|
|
1599
|
+
// the same in-memory state the command path uses, persisting through the
|
|
1600
|
+
// provided `persist` callback, and returns a human-readable string for the tool
|
|
1601
|
+
// result. Goal creation/replacement routes through the multi-goal registry
|
|
1602
|
+
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
1603
|
+
// path, so tool-created goals persist and are driven by the idle handler.
|
|
1604
|
+
function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
1605
|
+
async function getGoal(sessionID) {
|
|
1606
|
+
const goal = goalStates.get(sessionID)
|
|
1607
|
+
if (goal) return formatStatus(goal)
|
|
1608
|
+
const lastResult = lastGoalResults.get(sessionID)
|
|
1609
|
+
if (lastResult) return formatGoalResult(lastResult)
|
|
1610
|
+
return "No active goal."
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
async function getGoalHistory(sessionID) {
|
|
1614
|
+
const goal = goalStates.get(sessionID)
|
|
1615
|
+
if (goal) {
|
|
1616
|
+
return [
|
|
1617
|
+
`Goal history for: ${goal.condition}`,
|
|
1618
|
+
"",
|
|
1619
|
+
`Latest checkpoint: ${goal.lastCheckpoint?.summary || "none yet"}`,
|
|
1620
|
+
"",
|
|
1621
|
+
formatHistory(goal.history),
|
|
1622
|
+
].join("\n")
|
|
1623
|
+
}
|
|
1624
|
+
const lastResult = lastGoalResults.get(sessionID)
|
|
1625
|
+
if (lastResult) {
|
|
1626
|
+
return [
|
|
1627
|
+
`Last goal history for: ${lastResult.condition}`,
|
|
1628
|
+
"",
|
|
1629
|
+
`Latest checkpoint: ${lastResult.lastCheckpoint?.summary || "none recorded"}`,
|
|
1630
|
+
"",
|
|
1631
|
+
formatHistory(lastResult.history),
|
|
1632
|
+
].join("\n")
|
|
1633
|
+
}
|
|
1634
|
+
return "No goal history recorded yet."
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function setGoal(sessionID, args = {}) {
|
|
1638
|
+
const objective = typeof args.objective === "string" ? args.objective.trim() : ""
|
|
1639
|
+
if (!objective) return "No objective provided. Pass a non-empty `objective`."
|
|
1640
|
+
|
|
1641
|
+
const options = normalizeOptions({
|
|
1642
|
+
...defaultGoalOptions,
|
|
1643
|
+
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
1644
|
+
...(Number.isFinite(args.maxTokens) ? { maxTokens: args.maxTokens } : {}),
|
|
1645
|
+
...(Number.isFinite(args.maxDurationMs) ? { maxDurationMs: args.maxDurationMs } : {}),
|
|
1646
|
+
})
|
|
1647
|
+
const meta = {
|
|
1648
|
+
successCriteria: typeof args.successCriteria === "string" ? args.successCriteria : "",
|
|
1649
|
+
constraints: typeof args.constraints === "string" ? args.constraints : "",
|
|
1650
|
+
mode: typeof args.mode === "string" ? args.mode : "normal",
|
|
1651
|
+
}
|
|
1652
|
+
const goal = buildGoalState(sessionID, objective, options, meta)
|
|
1653
|
+
pushHistory(
|
|
1654
|
+
goal,
|
|
1655
|
+
"set",
|
|
1656
|
+
`Goal created via agent tool with limits: ${options.maxTurns} auto-continues, ${Math.round(options.maxDurationMs / 1000)}s, ${options.maxTokens.toLocaleString()} context tokens.`,
|
|
1657
|
+
)
|
|
1658
|
+
// Mirror the `/goal <condition>` replace path: discard the focused goal and
|
|
1659
|
+
// its saved result, drop any ordered sequence, then register + focus the new
|
|
1660
|
+
// goal so it persists and the idle handler drives it.
|
|
1661
|
+
sessionOrdered.delete(sessionID)
|
|
1662
|
+
cleanupGoal(sessionID)
|
|
1663
|
+
lastGoalResults.delete(sessionID)
|
|
1664
|
+
registerSessionGoal(goal)
|
|
1665
|
+
focusGoal(sessionID, goal)
|
|
1666
|
+
await persist()
|
|
1667
|
+
return `New active goal: ${goal.condition}`
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
async function updateGoal(sessionID, args = {}) {
|
|
1671
|
+
const goal = goalStates.get(sessionID)
|
|
1672
|
+
if (!goal) return "No active goal to update. Use set_goal first."
|
|
1673
|
+
|
|
1674
|
+
const messages = []
|
|
1675
|
+
|
|
1676
|
+
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
1677
|
+
goal.condition = args.objective.trim()
|
|
1678
|
+
goal.stopped = false
|
|
1679
|
+
goal.stopReason = ""
|
|
1680
|
+
goal.blockedReason = ""
|
|
1681
|
+
goal.budgetWrapupSent = false
|
|
1682
|
+
goal.noProgressTurns = 0
|
|
1683
|
+
goal.lastStatus = "Goal objective updated."
|
|
1684
|
+
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
1685
|
+
messages.push(`Objective updated: ${goal.condition}`)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (args.status !== undefined) {
|
|
1689
|
+
const status = String(args.status).trim().toLowerCase()
|
|
1690
|
+
if (!AGENT_UPDATE_STATUSES.has(status)) {
|
|
1691
|
+
return `Invalid status: ${args.status} (expected complete, blocked, paused, or resumed).`
|
|
1692
|
+
}
|
|
1693
|
+
if (status === "complete") {
|
|
1694
|
+
const evidence = typeof args.evidence === "string" ? args.evidence.trim() : ""
|
|
1695
|
+
goal.lastStatus = "Goal completed."
|
|
1696
|
+
pushHistory(
|
|
1697
|
+
goal,
|
|
1698
|
+
"completed",
|
|
1699
|
+
evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
|
|
1700
|
+
)
|
|
1701
|
+
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
1702
|
+
cleanupGoal(sessionID)
|
|
1703
|
+
// Advance an ordered (sisyphus) sequence just like the marker path does.
|
|
1704
|
+
if (sessionOrdered.has(sessionID)) promoteNextOrderedGoal(sessionID)
|
|
1705
|
+
await persist()
|
|
1706
|
+
return "Goal marked complete and archived."
|
|
1707
|
+
}
|
|
1708
|
+
if (status === "blocked") {
|
|
1709
|
+
goal.blockedReason = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
1710
|
+
goal.stopped = true
|
|
1711
|
+
goal.stopReason = "blocked"
|
|
1712
|
+
goal.lastStatus = "Assistant reported blocked."
|
|
1713
|
+
pushHistory(goal, "blocked", goal.blockedReason || "Marked blocked via agent tool.")
|
|
1714
|
+
messages.push("Goal marked blocked.")
|
|
1715
|
+
} else if (status === "paused") {
|
|
1716
|
+
goal.stopped = true
|
|
1717
|
+
goal.stopReason = "paused"
|
|
1718
|
+
goal.lastStatus = "Goal paused."
|
|
1719
|
+
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
1720
|
+
messages.push("Goal paused.")
|
|
1721
|
+
} else if (status === "resumed") {
|
|
1722
|
+
const previousGoalId = goal.goalId
|
|
1723
|
+
resetGoalBudget(goal)
|
|
1724
|
+
// resetGoalBudget rotates goalId; re-key the registry so the goal stays
|
|
1725
|
+
// findable by its new id (the focused pointer holds the same object).
|
|
1726
|
+
if (goal.goalId !== previousGoalId) {
|
|
1727
|
+
removeSessionGoal(sessionID, previousGoalId)
|
|
1728
|
+
registerSessionGoal(goal)
|
|
1729
|
+
focusGoal(sessionID, goal)
|
|
1730
|
+
}
|
|
1731
|
+
goal.stopped = false
|
|
1732
|
+
goal.stopReason = ""
|
|
1733
|
+
goal.blockedReason = ""
|
|
1734
|
+
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
1735
|
+
pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
|
|
1736
|
+
messages.push("Goal resumed with fresh limits.")
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (!messages.length) {
|
|
1741
|
+
return "Nothing to update. Provide `objective` and/or `status`."
|
|
1742
|
+
}
|
|
1743
|
+
await persist()
|
|
1744
|
+
return messages.join(" ")
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
async function clearGoal(sessionID) {
|
|
1748
|
+
// Mirror `/goal clear`: drop the ordered flag and the focused goal + result.
|
|
1749
|
+
sessionOrdered.delete(sessionID)
|
|
1750
|
+
cleanupGoal(sessionID)
|
|
1751
|
+
lastGoalResults.delete(sessionID)
|
|
1752
|
+
await persist()
|
|
1753
|
+
return "Goal cleared."
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function agentToolSessionID(ctx) {
|
|
1760
|
+
return ctx?.sessionID || ctx?.session_id || ctx?.session?.id || ctx?.sessionId || null
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Cache the optional @opencode-ai/plugin import once. It provides the `tool`
|
|
1764
|
+
// helper and `tool.schema` (zod). It is an optional peer dependency: when it is
|
|
1765
|
+
// not installed (e.g. unit tests, older OpenCode), tool registration is simply
|
|
1766
|
+
// skipped and the command/event hooks still work.
|
|
1767
|
+
let opencodePluginModulePromise
|
|
1768
|
+
async function loadOpencodePluginModule() {
|
|
1769
|
+
if (opencodePluginModulePromise === undefined) {
|
|
1770
|
+
opencodePluginModulePromise = import("@opencode-ai/plugin")
|
|
1771
|
+
.then((mod) => mod)
|
|
1772
|
+
.catch(() => null)
|
|
1773
|
+
}
|
|
1774
|
+
return opencodePluginModulePromise
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function buildAgentTools(toolHelper, handlers) {
|
|
1778
|
+
const schema = toolHelper.schema
|
|
1779
|
+
const run = (handler) => async (args, ctx) => {
|
|
1780
|
+
const sessionID = agentToolSessionID(ctx)
|
|
1781
|
+
if (!sessionID) return "No session id available for the goal tool."
|
|
1782
|
+
return handler(sessionID, args || {})
|
|
1783
|
+
}
|
|
1784
|
+
return {
|
|
1785
|
+
get_goal: toolHelper({
|
|
1786
|
+
description:
|
|
1787
|
+
"Get the status of the current goal for this session (objective, budget usage, last checkpoint).",
|
|
1788
|
+
args: {},
|
|
1789
|
+
execute: run((sessionID) => handlers.getGoal(sessionID)),
|
|
1790
|
+
}),
|
|
1791
|
+
get_goal_history: toolHelper({
|
|
1792
|
+
description: "Get the lifecycle history and latest checkpoint of the current goal for this session.",
|
|
1793
|
+
args: {},
|
|
1794
|
+
execute: run((sessionID) => handlers.getGoalHistory(sessionID)),
|
|
1795
|
+
}),
|
|
1796
|
+
set_goal: toolHelper({
|
|
1797
|
+
description:
|
|
1798
|
+
"Set a new session goal for autonomous auto-continue. ONLY call this when the user explicitly asks you to set, define, or start working toward a goal — never decide to set a goal on your own. Replaces any existing goal.",
|
|
1799
|
+
args: {
|
|
1800
|
+
objective: schema.string(),
|
|
1801
|
+
maxTurns: schema.number().optional(),
|
|
1802
|
+
maxTokens: schema.number().optional(),
|
|
1803
|
+
maxDurationMs: schema.number().optional(),
|
|
1804
|
+
successCriteria: schema.string().optional(),
|
|
1805
|
+
constraints: schema.string().optional(),
|
|
1806
|
+
mode: schema.string().optional(),
|
|
1807
|
+
},
|
|
1808
|
+
execute: run((sessionID, args) => handlers.setGoal(sessionID, args)),
|
|
1809
|
+
}),
|
|
1810
|
+
update_goal: toolHelper({
|
|
1811
|
+
description:
|
|
1812
|
+
"Update the current goal: revise its `objective`, and/or set its `status` to complete, blocked, paused, or resumed. Mark complete only after verifying the objective is truly done; include `evidence` (for complete) or `blocker` (for blocked).",
|
|
1813
|
+
args: {
|
|
1814
|
+
objective: schema.string().optional(),
|
|
1815
|
+
status: schema.string().optional(),
|
|
1816
|
+
evidence: schema.string().optional(),
|
|
1817
|
+
blocker: schema.string().optional(),
|
|
1818
|
+
},
|
|
1819
|
+
execute: run((sessionID, args) => handlers.updateGoal(sessionID, args)),
|
|
1820
|
+
}),
|
|
1821
|
+
clear_goal: toolHelper({
|
|
1822
|
+
description: "Clear the current goal for this session and discard its saved status.",
|
|
1823
|
+
args: {},
|
|
1824
|
+
execute: run((sessionID) => handlers.clearGoal(sessionID)),
|
|
1825
|
+
}),
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1593
1829
|
function formatGoalList(sessionID) {
|
|
1594
1830
|
const goals = listSessionGoals(sessionID)
|
|
1595
1831
|
const focusedId = goalStates.get(sessionID)?.goalId || null
|
|
@@ -1787,6 +2023,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1787
2023
|
await persist()
|
|
1788
2024
|
}
|
|
1789
2025
|
|
|
2026
|
+
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist })
|
|
2027
|
+
|
|
1790
2028
|
const hooks = {
|
|
1791
2029
|
"command.execute.before": async (input, output) => {
|
|
1792
2030
|
if (input.command !== commandName) return
|
|
@@ -1872,7 +2110,16 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1872
2110
|
return
|
|
1873
2111
|
}
|
|
1874
2112
|
|
|
2113
|
+
const previousGoalId = goal.goalId
|
|
1875
2114
|
resetGoalBudget(goal)
|
|
2115
|
+
// resetGoalBudget rotates goalId; re-key the multi-goal registry to the
|
|
2116
|
+
// new id so a later clear/replace removes the goal instead of leaking a
|
|
2117
|
+
// stale entry (the focused pointer holds the same object reference).
|
|
2118
|
+
if (goal.goalId !== previousGoalId) {
|
|
2119
|
+
removeSessionGoal(sessionID, previousGoalId)
|
|
2120
|
+
registerSessionGoal(goal)
|
|
2121
|
+
focusGoal(sessionID, goal)
|
|
2122
|
+
}
|
|
1876
2123
|
goal.stopped = false
|
|
1877
2124
|
goal.stopReason = ""
|
|
1878
2125
|
goal.blockedReason = ""
|
|
@@ -2355,12 +2602,23 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2355
2602
|
return
|
|
2356
2603
|
}
|
|
2357
2604
|
|
|
2605
|
+
// Hoist tool-call check so both the noProgress and noToolCall gates can
|
|
2606
|
+
// use it. A tool call is evidence of real work even when prose output
|
|
2607
|
+
// is tiny (e.g. a thinking model that calls a tool with < 50 output
|
|
2608
|
+
// tokens), so it resets noProgressTurns the same way the noToolCall
|
|
2609
|
+
// gate already resets noToolCallTurns.
|
|
2610
|
+
const latestHasToolCall = messageHasToolCall(latestAssistant)
|
|
2611
|
+
|
|
2358
2612
|
const lowOutputTurn =
|
|
2359
2613
|
activeGoalAfterMessages.turnCount > 0 &&
|
|
2360
2614
|
latestOutputTokens !== null &&
|
|
2361
2615
|
latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
|
|
2616
|
+
// A turn that used a tool is never stalled even with low output tokens:
|
|
2617
|
+
// reasoning-heavy models often produce small prose output while doing
|
|
2618
|
+
// real work via tool calls. Excluding tool-call turns prevents false
|
|
2619
|
+
// noProgress pauses on thinking models.
|
|
2362
2620
|
const lowOutputLooksStalled =
|
|
2363
|
-
lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
|
|
2621
|
+
lowOutputTurn && !latestHasToolCall && (assistantRepeated || !latestText || !assistantChanged)
|
|
2364
2622
|
if (lowOutputLooksStalled) {
|
|
2365
2623
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
2366
2624
|
if (
|
|
@@ -2395,7 +2653,6 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2395
2653
|
// configured grace window. Complements the low-output check above:
|
|
2396
2654
|
// a turn can be high-output yet still make no real progress because it
|
|
2397
2655
|
// never touched a tool.
|
|
2398
|
-
const latestHasToolCall = messageHasToolCall(latestAssistant)
|
|
2399
2656
|
const noToolCallContinuation =
|
|
2400
2657
|
activeGoalAfterMessages.turnCount > 0 && Boolean(latestAssistant) && !latestHasToolCall
|
|
2401
2658
|
if (noToolCallContinuation) {
|
|
@@ -2449,14 +2706,35 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2449
2706
|
activeGoalBeforePrompt.lastContinueAt = Date.now()
|
|
2450
2707
|
if (!budgetWrapup) {
|
|
2451
2708
|
if (completionUnverified) {
|
|
2709
|
+
activeGoalBeforePrompt.formatFailures += 1
|
|
2452
2710
|
activeGoalBeforePrompt.lastStatus = `Rejected an unverified [goal:complete] (no [goal:evidence]); re-prompting for evidence on turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2453
2711
|
} else if (blockerUnstated) {
|
|
2712
|
+
activeGoalBeforePrompt.formatFailures += 1
|
|
2454
2713
|
activeGoalBeforePrompt.lastStatus = `Rejected a [goal:blocked] with no concrete blocker; re-prompting on turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2455
2714
|
} else {
|
|
2715
|
+
activeGoalBeforePrompt.formatFailures = 0
|
|
2456
2716
|
activeGoalBeforePrompt.lastStatus = latestText
|
|
2457
2717
|
? `Continuing after assistant turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2458
2718
|
: `Continuing after idle event ${activeGoalBeforePrompt.turnCount}.`
|
|
2459
2719
|
}
|
|
2720
|
+
|
|
2721
|
+
// Pause after too many consecutive format-validation failures. Unlike
|
|
2722
|
+
// promptFailures (which counts network/protocol errors), this counts turns
|
|
2723
|
+
// where the model signalled completion or a blocker but omitted the required
|
|
2724
|
+
// evidence or concrete-blocker line. The same maxPromptFailures cap applies;
|
|
2725
|
+
// resume resets the counter via resetGoalBudget.
|
|
2726
|
+
if (activeGoalBeforePrompt.formatFailures >= activeGoalBeforePrompt.options.maxPromptFailures) {
|
|
2727
|
+
activeGoalBeforePrompt.stopped = true
|
|
2728
|
+
activeGoalBeforePrompt.stopReason = "format validation failures"
|
|
2729
|
+
activeGoalBeforePrompt.lastStatus = `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s) (missing [goal:evidence] or concrete blocker). Run /${commandName} resume to retry.`
|
|
2730
|
+
pushHistory(
|
|
2731
|
+
activeGoalBeforePrompt,
|
|
2732
|
+
"paused",
|
|
2733
|
+
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
2734
|
+
)
|
|
2735
|
+
await persist()
|
|
2736
|
+
return
|
|
2737
|
+
}
|
|
2460
2738
|
}
|
|
2461
2739
|
|
|
2462
2740
|
const response = await client.session.promptAsync({
|
|
@@ -2531,13 +2809,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2531
2809
|
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
2532
2810
|
if (systemBlocks.some(systemBlockContainsGoal)) return
|
|
2533
2811
|
|
|
2812
|
+
// Only static content here — volatile fields (limit warnings, turn counters,
|
|
2813
|
+
// token counts, wall-clock values) must not appear in the system prompt.
|
|
2814
|
+
// system.transform fires on every provider request including tool-call
|
|
2815
|
+
// sub-requests; any per-turn drift in the system prompt invalidates the
|
|
2816
|
+
// provider-side prefix cache from byte 0, turning O(1) cache hits into
|
|
2817
|
+
// O(N*turns) full-context misses. Limit warnings are already delivered
|
|
2818
|
+
// on every continuation turn via buildContinueMessage (buildLimitWarning
|
|
2819
|
+
// and <progress_budget>), which is sufficient — the model doesn't need
|
|
2820
|
+
// them in the system prompt mid-turn.
|
|
2534
2821
|
const goalBlock = [
|
|
2535
2822
|
buildGoalBlock(goal),
|
|
2536
2823
|
"Keep working until the goal is fully satisfied.",
|
|
2537
2824
|
"When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
|
|
2538
2825
|
"If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.",
|
|
2539
|
-
|
|
2540
|
-
].filter(Boolean).join("\n")
|
|
2826
|
+
].join("\n")
|
|
2541
2827
|
|
|
2542
2828
|
if (systemBlocks.length === 0) {
|
|
2543
2829
|
output.system = [goalBlock]
|
|
@@ -2583,6 +2869,22 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2583
2869
|
delete hooks["command.execute.before"]
|
|
2584
2870
|
}
|
|
2585
2871
|
|
|
2872
|
+
// Register agent-facing tools (megalist 7.1 / 7.2) when @opencode-ai/plugin is
|
|
2873
|
+
// available (it provides the `tool` helper and zod-style schema). Disabled via
|
|
2874
|
+
// `registerTools: false`. When the helper is absent the command/event hooks
|
|
2875
|
+
// still work; only the programmatic tool surface is omitted, preserving the
|
|
2876
|
+
// zero-runtime-dependency posture.
|
|
2877
|
+
if (pluginOptions.registerTools !== false) {
|
|
2878
|
+
const toolModule = await loadOpencodePluginModule()
|
|
2879
|
+
if (toolModule?.tool?.schema) {
|
|
2880
|
+
try {
|
|
2881
|
+
hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers)
|
|
2882
|
+
} catch (error) {
|
|
2883
|
+
await logPluginError(client, "Failed to register goal agent tools", error)
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2586
2888
|
return hooks
|
|
2587
2889
|
}
|
|
2588
2890
|
|
|
@@ -2593,6 +2895,9 @@ export default {
|
|
|
2593
2895
|
|
|
2594
2896
|
export const testInternals = {
|
|
2595
2897
|
activeGoal,
|
|
2898
|
+
agentToolSessionID,
|
|
2899
|
+
buildAgentToolHandlers,
|
|
2900
|
+
buildAgentTools,
|
|
2596
2901
|
listSessionGoals,
|
|
2597
2902
|
formatGoalList,
|
|
2598
2903
|
appendLedgerLine,
|