opencode-goal-plugin 0.6.1 → 0.6.3

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.
@@ -88,6 +88,7 @@ function createRuntimeState() {
88
88
  activeContinues: new Map(),
89
89
  continuationControllers: new Map(),
90
90
  seenIdleEventIDs: new Set(),
91
+ readOnlyCommandGuards: new Set(),
91
92
  ledgerSink: null,
92
93
  persistenceLease: null,
93
94
  migrationLease: null,
@@ -143,6 +144,7 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
143
144
  const activeContinues = runtimeCollection("activeContinues")
144
145
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
145
146
  const PAUSE_COMMANDS = new Set(["pause"])
147
+ const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
146
148
  const GOAL_FLAG_SPECS = {
147
149
  "--max-turns": {
148
150
  optionKey: "maxTurns",
@@ -206,7 +208,7 @@ function messageHasToolCall(message) {
206
208
 
207
209
  const GOAL_MODES = new Set(["normal", "ordered"])
208
210
 
209
- // Goal "mode" field (item 4.3): normal vs ordered (a.k.a. sisyphus). `ordered`
211
+ // Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered`
210
212
  // signals a strict execution sequence; `sisyphus` is accepted as an alias.
211
213
  // Returns the canonical mode or null when unrecognized.
212
214
  function normalizeMode(value) {
@@ -289,12 +291,12 @@ function makeHistoryEntry(type, detail, timestamp = Date.now()) {
289
291
  }
290
292
  }
291
293
 
292
- // Append-only lifecycle ledger (item 2.3). pushHistory emits every lifecycle
294
+ // Append-only lifecycle ledger. pushHistory emits every lifecycle
293
295
  // event to this sink, which a configured plugin instance points at a JSONL
294
296
  // file. Because the in-memory history is truncated to MAX_HISTORY_ENTRIES, the
295
297
  // ledger is the durable record used to reconstruct state if the main state file
296
298
  // is lost or corrupted, and it captures terminal events even when the main
297
- // state write fails (fail-closed, item 2.5).
299
+ // state write fails (fail closed).
298
300
  function setLedgerSink(sink) {
299
301
  currentRuntime().ledgerSink = typeof sink === "function" ? sink : null
300
302
  }
@@ -705,6 +707,7 @@ function clearRuntimeState() {
705
707
  activeContinues.clear()
706
708
  runtime.continuationControllers.clear()
707
709
  runtime.seenIdleEventIDs.clear()
710
+ runtime.readOnlyCommandGuards.clear()
708
711
  }
709
712
 
710
713
  function pruneGoalResults(options) {
@@ -1001,7 +1004,7 @@ async function assertSafeProjectPersistencePath({ stateFilePath, projectRoot, en
1001
1004
  }
1002
1005
  }
1003
1006
 
1004
- // Command surface options (item 8.2): `commandName` lets the plugin own a
1007
+ // Command surface options: `commandName` lets the plugin own a
1005
1008
  // different slash command (e.g. /objective) and `registerCommand: false` makes
1006
1009
  // the plugin skip the command hook entirely (agent/programmatic use only). A
1007
1010
  // leading slash in commandName is tolerated and stripped.
@@ -1418,7 +1421,7 @@ async function loadPersistedState(persistenceOptions, client) {
1418
1421
 
1419
1422
  // Last-resort recovery: when the main state file is absent, rebuild still-active
1420
1423
  // goals from the append-only ledger so a lost/rotated state file does not drop
1421
- // in-flight goals (item 2.3). Recovered goals are paused (via deserializeGoal).
1424
+ // in-flight goals. Recovered goals are paused (via deserializeGoal).
1422
1425
  async function reconstructFromLedger(persistenceOptions, client) {
1423
1426
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1424
1427
  maxBytes: persistenceOptions.ledgerMaxBytes,
@@ -1753,11 +1756,16 @@ function buildContinueMessage(
1753
1756
  )
1754
1757
  } else {
1755
1758
  lines.push(
1756
- "Continue with the next concrete step; inspect current state and repair failures.",
1759
+ "Continue the next concrete step; inspect and repair failures.",
1757
1760
  )
1758
1761
  }
1759
1762
 
1760
- lines.push("Complete only after verification: `[goal:evidence] …` then `[goal:complete]`. If only user input can unblock work, state why then `[goal:blocked]`.")
1763
+ lines.push(
1764
+ "Completion format—consecutive plain lines; no Markdown/backticks/blank line:",
1765
+ "[goal:evidence] <proof>",
1766
+ "[goal:complete]",
1767
+ "Need user input? State why before [goal:blocked].",
1768
+ )
1761
1769
  const limitWarning = buildLimitWarning(goal)
1762
1770
  if (limitWarning) lines.push(limitWarning.trim())
1763
1771
 
@@ -1788,7 +1796,7 @@ function buildContinueMessage(
1788
1796
 
1789
1797
  // Deterministic progress summary built from the plugin's persisted goal record
1790
1798
  // (checkpoints + lifecycle history) rather than from chat memory, so it is
1791
- // stable and reproducible across a compaction (item 6.3).
1799
+ // stable and reproducible across a compaction.
1792
1800
  function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents = 6 } = {}) {
1793
1801
  const lines = []
1794
1802
  const checkpoints = Array.isArray(goal.checkpoints) ? goal.checkpoints.slice(-maxCheckpoints) : []
@@ -2069,7 +2077,7 @@ function isPluginContinuationMessage(message) {
2069
2077
 
2070
2078
  // "Latest instruction wins": detect a real (human) user message that arrived
2071
2079
  // after the plugin's most recent continuation prompt. Plugin-generated
2072
- // continuation/audit messages are ignored (item 5.2). Detection requires the
2080
+ // continuation/audit messages are ignored. Detection requires the
2073
2081
  // loop to be running (turnCount > 0) and a plugin continuation to be visible in
2074
2082
  // the recent window, so the first idle after /goal set and sessions where the
2075
2083
  // continuations have scrolled out of view are never misread as intervention.
@@ -2139,7 +2147,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2139
2147
  const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
2140
2148
 
2141
2149
  // Programmatic equivalents of the /goal command, exposed to the agent as tools
2142
- // (megalist items 7.1 / 7.2). Each handler operates on a session id and mutates
2150
+ // Each handler operates on a session id and mutates
2143
2151
  // the same in-memory state the command path uses, persisting through the
2144
2152
  // provided `persist` callback, and returns a human-readable string for the tool
2145
2153
  // result. Goal creation/replacement routes through the multi-goal registry
@@ -2356,12 +2364,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2356
2364
  } else if (status === "resumed") {
2357
2365
  if (!goal.stopped)
2358
2366
  return "Goal is already running. Pause or stop it first if you want to reset the budget window."
2359
- const previousGoalId = goal.goalId
2360
2367
  resetGoalBudget(goal)
2361
- // resetGoalBudget always rotates goalId via randomUUID; unconditionally
2362
- // re-key the registry so the goal stays findable by its new id.
2363
- removeSessionGoal(sessionID, previousGoalId)
2364
- registerSessionGoal(goal)
2368
+ // goalId is stable across budget windows; runId is the execution epoch.
2369
+ // Keeping the existing registry entry also preserves multi-goal order.
2365
2370
  focusGoal(sessionID, goal)
2366
2371
  goal.stopped = false
2367
2372
  goal.stopReason = ""
@@ -2594,7 +2599,7 @@ function formatGoalList(sessionID, commandName = "goal") {
2594
2599
  return lines.join("\n")
2595
2600
  }
2596
2601
 
2597
- // Visible audit messages (item 2.4): when the plugin audits a completion or
2602
+ // Visible audit messages: when the plugin audits a completion or
2598
2603
  // blocker it announces the audit and its result instead of doing the work
2599
2604
  // silently. Delivery is via this default messenger (structured app log, the
2600
2605
  // channel OpenCode surfaces to the user) or a caller-supplied `auditMessenger`
@@ -2623,7 +2628,7 @@ async function defaultAuditMessenger(client, sessionID, text) {
2623
2628
  }
2624
2629
  }
2625
2630
 
2626
- // Completion auditor (item 2.2). When an auditor is configured, a [goal:complete]
2631
+ // Completion auditor. When an auditor is configured, a [goal:complete]
2627
2632
  // is verified before the goal is archived: an approved verdict archives it, a
2628
2633
  // rejected verdict restores the goal (pauses it with the reason) instead of
2629
2634
  // archiving. The auditor is a function `({ goal, sessionID, latestText }) =>
@@ -2783,7 +2788,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2783
2788
  }
2784
2789
  runtime.drainPersistence = () => persistChain.catch(() => false)
2785
2790
 
2786
- // Fail-closed (item 2.5): when persisting a terminal state (complete/blocked)
2791
+ // Fail closed when persisting a terminal state (complete/blocked)
2787
2792
  // fails, surface it loudly. The terminal event is already in the append-only
2788
2793
  // ledger, so it stays recoverable across a restart even though the main state
2789
2794
  // file write did not land.
@@ -2810,7 +2815,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2810
2815
  setLedgerSink(null)
2811
2816
  }
2812
2817
 
2813
- // Visible audit announcements (item 2.4).
2818
+ // Visible audit announcements.
2814
2819
  const auditMessagesEnabled = pluginOptions.auditMessages !== false
2815
2820
  const auditMessenger =
2816
2821
  typeof pluginOptions.auditMessenger === "function"
@@ -2885,6 +2890,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2885
2890
  })
2886
2891
  if (pluginOptions.completionAudit) verifierRegistrationReady = true
2887
2892
  },
2893
+ "tool.execute.before": async (input) => {
2894
+ const sessionID = input?.sessionID
2895
+ if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
2896
+ if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
2897
+ throw new Error(
2898
+ `This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`,
2899
+ )
2900
+ },
2888
2901
  "command.execute.before": async (input, output) => {
2889
2902
  if (!input || input.command !== commandName || !output) return
2890
2903
 
@@ -2898,10 +2911,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2898
2911
  }
2899
2912
  const args = input.arguments.trim()
2900
2913
  const sessionID = input.sessionID
2914
+ currentRuntime().readOnlyCommandGuards.delete(sessionID)
2901
2915
  pruneGoalResults(defaultGoalOptions)
2902
2916
 
2903
2917
  if (!args || args === "status") {
2904
2918
  const goal = goalStates.get(sessionID)
2919
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2905
2920
  const lastResult = lastGoalResults.get(sessionID)
2906
2921
  output.parts = [
2907
2922
  makeTextPart(
@@ -2917,6 +2932,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2917
2932
 
2918
2933
  if (args === "history") {
2919
2934
  const goal = goalStates.get(sessionID)
2935
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2920
2936
  const lastResult = lastGoalResults.get(sessionID)
2921
2937
  output.parts = [
2922
2938
  makeTextPart(
@@ -2943,6 +2959,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2943
2959
  }
2944
2960
 
2945
2961
  if (CLEAR_COMMANDS.has(args)) {
2962
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2946
2963
  // Record the clear in the ledger before cleanupGoal removes the goal
2947
2964
  // object, so reconstructFromLedger can identify cleared goals and skip
2948
2965
  // them rather than reconstructing them after a missing state file.
@@ -2962,6 +2979,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2962
2979
  }
2963
2980
 
2964
2981
  if (PAUSE_COMMANDS.has(args)) {
2982
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2965
2983
  const goal = goalStates.get(sessionID)
2966
2984
  if (!goal) {
2967
2985
  output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
@@ -2987,12 +3005,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2987
3005
  return
2988
3006
  }
2989
3007
 
2990
- const previousGoalId = goal.goalId
2991
3008
  resetGoalBudget(goal)
2992
- // resetGoalBudget always rotates goalId via randomUUID; unconditionally
2993
- // re-key the registry so a later clear/replace removes the right entry.
2994
- removeSessionGoal(sessionID, previousGoalId)
2995
- registerSessionGoal(goal)
3009
+ // goalId is stable across budget windows; runId is the execution epoch.
3010
+ // Keeping the existing registry entry also preserves multi-goal order.
2996
3011
  focusGoal(sessionID, goal)
2997
3012
  goal.stopped = false
2998
3013
  goal.stopReason = ""
@@ -3052,6 +3067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3052
3067
  }
3053
3068
 
3054
3069
  if (args === "list") {
3070
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
3055
3071
  output.parts = [makeTextPart(formatGoalList(sessionID, commandName))]
3056
3072
  return
3057
3073
  }
@@ -3394,6 +3410,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3394
3410
  if (!isIdleEvent(event)) return
3395
3411
 
3396
3412
  const sessionID = getSessionID(event)
3413
+ currentRuntime().readOnlyCommandGuards.delete(sessionID)
3397
3414
  const eventID = typeof event?.id === "string" ? event.id : ""
3398
3415
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
3399
3416
  if (eventID && seenIdleEventIDs.has(eventID)) return
@@ -3478,7 +3495,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3478
3495
  // bail out without archiving — archiving a cleared goal would resurrect
3479
3496
  // it in memory and potentially in the persisted state.
3480
3497
  if (!activeGoal(sessionID, goalID, runID)) return
3481
- // Optional independent auditor (item 2.2): an approved verdict
3498
+ // Optional independent auditor: an approved verdict
3482
3499
  // archives; a rejected verdict restores (pauses) the goal instead.
3483
3500
  if (completionAuditor) {
3484
3501
  let verdict
@@ -3867,7 +3884,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3867
3884
 
3868
3885
  const goal = goalStates.get(input.sessionID)
3869
3886
  if (!goal) return
3870
- if (goal.stopped) return
3871
3887
  const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
3872
3888
  if (systemBlocks.some((block) => systemBlockContainsGoal(block, goal.goalId))) return
3873
3889
 
@@ -3880,14 +3896,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3880
3896
  // on every continuation turn via buildContinueMessage (buildLimitWarning
3881
3897
  // and <progress_budget>), which is sufficient — the model doesn't need
3882
3898
  // them in the system prompt mid-turn.
3883
- const goalBlock = [
3884
- `<opencode_goal_plugin id="${goal.goalId}">`,
3885
- buildGoalBlock(goal),
3886
- "Keep working until the goal is fully satisfied.",
3887
- "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
3888
- "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.",
3889
- "</opencode_goal_plugin>",
3890
- ].join("\n")
3899
+ const goalBlock = goal.stopped
3900
+ ? [
3901
+ `<opencode_goal_plugin id="${goal.goalId}">`,
3902
+ "<goal_state>paused</goal_state>",
3903
+ "A goal exists for this session, but it is paused. Do not continue or modify work toward it, and do not call completion or blocker tools, unless the current user message explicitly asks to resume it.",
3904
+ "For status or history requests, only report the goal state; do not change files or goal state.",
3905
+ `To continue, the user can run /${commandName} resume or explicitly ask you to call goal_resume before doing any goal work.`,
3906
+ "</opencode_goal_plugin>",
3907
+ ].join("\n")
3908
+ : [
3909
+ `<opencode_goal_plugin id="${goal.goalId}">`,
3910
+ buildGoalBlock(goal),
3911
+ "Keep working until the goal is fully satisfied.",
3912
+ "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
3913
+ "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.",
3914
+ "</opencode_goal_plugin>",
3915
+ ].join("\n")
3891
3916
 
3892
3917
  if (systemBlocks.length === 0) {
3893
3918
  output.system = [goalBlock]
@@ -3930,13 +3955,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3930
3955
  },
3931
3956
  }
3932
3957
 
3933
- // register_command toggle (item 8.2): when disabled, the plugin does not own
3958
+ // register_command toggle: when disabled, the plugin does not own
3934
3959
  // a slash command and only the event/transform/compaction hooks remain.
3935
3960
  if (!registerCommand) {
3936
3961
  delete hooks["command.execute.before"]
3937
3962
  }
3938
3963
 
3939
- // Register agent-facing tools (megalist 7.1 / 7.2) when @opencode-ai/plugin is
3964
+ // Register agent-facing tools when @opencode-ai/plugin is
3940
3965
  // available (it provides the `tool` helper and zod-style schema). Disabled via
3941
3966
  // `registerTools: false`. When the helper is absent the command/event hooks
3942
3967
  // still work; only the programmatic tool surface is omitted, preserving the
@@ -1,272 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import assert from "node:assert/strict"
4
- import { promises as fs } from "node:fs"
5
- import { tmpdir } from "node:os"
6
- import { join } from "node:path"
7
- import { GoalPlugin } from "../src/goal-plugin.js"
8
-
9
- const startedAt = performance.now()
10
- const temporaryDirectories = []
11
-
12
- function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) {
13
- return {
14
- info: {
15
- id,
16
- role: "assistant",
17
- sessionID,
18
- tokens: { input: 20, output: 120, reasoning: 0 },
19
- },
20
- parts: [{ type: "text", text }],
21
- }
22
- }
23
-
24
- function createHost(messageForSession = () => "Working with tools.") {
25
- const prompts = []
26
- const notices = []
27
- return {
28
- prompts,
29
- notices,
30
- client: {
31
- app: { log: async () => {} },
32
- session: {
33
- messages: async ({ path }) => ({
34
- data: [assistantMessage(path.id, messageForSession(path.id))],
35
- }),
36
- promptAsync: async (input) => {
37
- prompts.push(input)
38
- return {}
39
- },
40
- },
41
- },
42
- }
43
- }
44
-
45
- function promptCharacters(prompts) {
46
- return prompts.reduce(
47
- (total, prompt) => total + (prompt?.body?.parts || []).reduce(
48
- (partTotal, part) => partTotal + (typeof part?.text === "string" ? part.text.length : 0),
49
- 0,
50
- ),
51
- 0,
52
- )
53
- }
54
-
55
- async function makeDirectory(label) {
56
- const directory = await fs.mkdtemp(join(tmpdir(), `goal-benchmark-${label}-`))
57
- temporaryDirectories.push(directory)
58
- return directory
59
- }
60
-
61
- async function createHooks(host, options = {}) {
62
- return GoalPlugin(
63
- { client: host.client, directory: await makeDirectory("workspace") },
64
- {
65
- persistState: false,
66
- registerTools: false,
67
- registerAgents: false,
68
- minDelayMs: 1,
69
- noProgressTokenThreshold: 1,
70
- noProgressTurnsBeforePause: 10,
71
- noToolCallTurnsBeforePause: 2,
72
- ...options,
73
- },
74
- )
75
- }
76
-
77
- async function goalCommand(hooks, sessionID, argumentsText) {
78
- const output = { parts: [] }
79
- await hooks["command.execute.before"](
80
- { command: "goal", sessionID, arguments: argumentsText },
81
- output,
82
- )
83
- return output.parts.map((part) => part.text || "").join("\n")
84
- }
85
-
86
- async function idle(hooks, sessionID, id) {
87
- await hooks.event({
88
- event: {
89
- id,
90
- type: "session.status",
91
- properties: { sessionID, status: { type: "idle" } },
92
- },
93
- })
94
- }
95
-
96
- async function scenario(name, points, run) {
97
- const scenarioStartedAt = performance.now()
98
- try {
99
- const telemetry = await run()
100
- return {
101
- name,
102
- passed: true,
103
- points,
104
- durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
105
- ...telemetry,
106
- }
107
- } catch (error) {
108
- return {
109
- name,
110
- passed: false,
111
- points: 0,
112
- possiblePoints: points,
113
- durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
114
- error: error instanceof Error ? error.message : String(error),
115
- }
116
- }
117
- }
118
-
119
- const results = []
120
-
121
- results.push(await scenario("verified-success", 20, async () => {
122
- const sessionID = "benchmark-success"
123
- const host = createHost(() => "Tests pass.\n[goal:evidence] npm test: 210/210\n[goal:complete]")
124
- const hooks = await createHooks(host, {
125
- auditor: async () => ({ approved: true, reason: "evidence independently accepted" }),
126
- })
127
- await goalCommand(hooks, sessionID, "ship a verified release")
128
- await idle(hooks, sessionID, "success-idle")
129
- const status = await goalCommand(hooks, sessionID, "status")
130
- assert.match(status, /State: achieved/)
131
- await hooks.dispose()
132
- return {
133
- continuationPrompts: host.prompts.length,
134
- continuationCharacters: promptCharacters(host.prompts),
135
- status: "archived",
136
- }
137
- }))
138
-
139
- results.push(await scenario("false-completion", 20, async () => {
140
- const sessionID = "benchmark-false-completion"
141
- const host = createHost(() => "Looks done.\n[goal:evidence] guessed from source\n[goal:complete]")
142
- const hooks = await createHooks(host, {
143
- auditor: async () => ({ approved: false, reason: "no executed verification" }),
144
- })
145
- await goalCommand(hooks, sessionID, "do not accept an unverified claim")
146
- await idle(hooks, sessionID, "false-idle")
147
- const status = await goalCommand(hooks, sessionID, "status")
148
- assert.match(status, /audit rejected/i)
149
- assert.doesNotMatch(status, /No active goal/)
150
- await hooks.dispose()
151
- return {
152
- continuationPrompts: host.prompts.length,
153
- continuationCharacters: promptCharacters(host.prompts),
154
- status: "rejected",
155
- }
156
- }))
157
-
158
- results.push(await scenario("loop-circuit-breaker", 15, async () => {
159
- const sessionID = "benchmark-loop"
160
- let turn = 0
161
- const host = createHost(() => `Still discussing the work, turn ${turn++}.`)
162
- const hooks = await createHooks(host)
163
- await goalCommand(hooks, sessionID, "stop self-chat loops")
164
- await idle(hooks, sessionID, "loop-1")
165
- await idle(hooks, sessionID, "loop-2")
166
- await idle(hooks, sessionID, "loop-3")
167
- const status = await goalCommand(hooks, sessionID, "status")
168
- assert.match(status, /no tool calls|self-chat loop/i)
169
- assert.equal(host.prompts.length, 2)
170
- await hooks.dispose()
171
- return {
172
- continuationPrompts: host.prompts.length,
173
- continuationCharacters: promptCharacters(host.prompts),
174
- status: "paused",
175
- }
176
- }))
177
-
178
- results.push(await scenario("human-interruption", 15, async () => {
179
- const sessionID = "benchmark-interruption"
180
- const host = createHost()
181
- const hooks = await createHooks(host)
182
- await goalCommand(hooks, sessionID, "respect explicit interruption")
183
- await hooks.event({
184
- event: {
185
- type: "session.error",
186
- properties: {
187
- sessionID,
188
- error: { name: "MessageAbortedError", message: "aborted by user" },
189
- },
190
- },
191
- })
192
- await idle(hooks, sessionID, "interruption-idle")
193
- assert.equal(host.prompts.length, 0)
194
- assert.match(await goalCommand(hooks, sessionID, "status"), /abort|paused|stopped/i)
195
- await hooks.dispose()
196
- return { continuationPrompts: 0, status: "paused" }
197
- }))
198
-
199
- results.push(await scenario("compaction-continuity", 15, async () => {
200
- const sessionID = "benchmark-compaction"
201
- const host = createHost()
202
- const hooks = await createHooks(host)
203
- await goalCommand(hooks, sessionID, "preserve the objective across compaction")
204
- const output = { context: [] }
205
- await hooks["experimental.session.compacting"]({ sessionID }, output)
206
- assert.equal(output.context.length, 1)
207
- assert.match(output.context[0], /preserve the objective across compaction/)
208
- assert.ok(output.context[0].length < 2_000, "compaction context exceeded token-efficient size cap")
209
- await hooks.dispose()
210
- return {
211
- contextCharacters: output.context[0].length,
212
- estimatedContextTokens: Math.ceil(output.context[0].length / 4),
213
- status: "preserved",
214
- }
215
- }))
216
-
217
- results.push(await scenario("restart-recovery", 15, async () => {
218
- const sessionID = "benchmark-restart"
219
- const directory = await makeDirectory("restart")
220
- const stateFilePath = join(directory, "state.json")
221
- const host = createHost()
222
- const first = await GoalPlugin(
223
- { client: host.client, directory },
224
- { persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
225
- )
226
- await goalCommand(first, sessionID, "recover safely after restart")
227
- await first.dispose()
228
- const second = await GoalPlugin(
229
- { client: host.client, directory },
230
- { persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
231
- )
232
- const status = await goalCommand(second, sessionID, "status")
233
- assert.match(status, /Recovered persisted goal state|recovered after restart/i)
234
- await idle(second, sessionID, "restart-idle")
235
- assert.equal(host.prompts.length, 0, "recovered goals must not resume without user consent")
236
- const stateBytes = (await fs.stat(stateFilePath)).size
237
- await second.dispose()
238
- return { continuationPrompts: 0, persistedStateBytes: stateBytes, status: "recovered-paused" }
239
- }))
240
-
241
- const score = results.reduce((total, result) => total + result.points, 0)
242
- const possibleScore = 100
243
- const continuationCharacters = results.reduce(
244
- (total, result) => total + (result.continuationCharacters || 0),
245
- 0,
246
- )
247
- const report = {
248
- schemaVersion: 1,
249
- benchmark: "opencode-goal-plugin-behavior",
250
- score,
251
- possibleScore,
252
- passed: score === possibleScore,
253
- durationMs: Number((performance.now() - startedAt).toFixed(2)),
254
- efficiency: {
255
- totalContinuationPrompts: results.reduce(
256
- (total, result) => total + (result.continuationPrompts || 0),
257
- 0,
258
- ),
259
- continuationCharacters,
260
- estimatedContinuationTokens: Math.ceil(continuationCharacters / 4),
261
- modelCalls: 0,
262
- externalRequests: 0,
263
- },
264
- scenarios: results,
265
- }
266
-
267
- for (const directory of temporaryDirectories) {
268
- await fs.rm(directory, { recursive: true, force: true })
269
- }
270
-
271
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
272
- if (!report.passed) process.exitCode = 1