opencode-goal-plugin 0.6.0 → 0.6.2

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.
@@ -1,6 +1,6 @@
1
1
  const GOAL_AGENT_PROMPT = `Execute explicit goals persistently. Use goal tools to track state and checkpoints. Make concrete progress; claim completion only with verification evidence. Report only genuine blockers.`
2
2
 
3
- const VERIFIER_AGENT_PROMPT = `Independently verify the claim against the goal, constraints, evidence, and workspace. Use read-only checks; never edit or mutate goal state. Approve only when proven; otherwise give one actionable reason.`
3
+ const VERIFIER_AGENT_PROMPT = `Independently verify the claim against the goal, constraints, evidence, and workspace. Use only the read, glob, and grep tools; never edit, execute commands, call other tools, or mutate goal state. Approve only when proven; otherwise give one actionable reason.`
4
4
 
5
5
  export function applyNativeGoalConfig(config, options = {}) {
6
6
  if (!config || typeof config !== "object" || Array.isArray(config)) {
@@ -40,6 +40,10 @@ export function applyNativeGoalConfig(config, options = {}) {
40
40
  hidden: true,
41
41
  prompt: VERIFIER_AGENT_PROMPT,
42
42
  permission: {
43
+ "*": "deny",
44
+ read: "allow",
45
+ glob: "allow",
46
+ grep: "allow",
43
47
  edit: "deny",
44
48
  bash: "deny",
45
49
  },
@@ -5,6 +5,11 @@ const SHAPE_ERROR_PATTERNS = [
5
5
  /(?:validation|schema|invalid input|invalid argument)/i,
6
6
  ]
7
7
 
8
+ // Only read-only operations may be retried with another argument shape. A
9
+ // TypeError can be raised after a mutating SDK call has already reached the
10
+ // host, so replaying create/prompt/update/delete/abort could duplicate side effects.
11
+ const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get"])
12
+
8
13
  function isArgumentShapeError(error) {
9
14
  if (!(error instanceof TypeError)) return false
10
15
  const message = String(error.message || "")
@@ -46,7 +51,9 @@ export function createOpenCodeSessionApi(client, options = {}) {
46
51
  shapes.set(operation, firstShape)
47
52
  return unwrapData(response)
48
53
  } catch (error) {
49
- if (knownShape || !isArgumentShapeError(error)) throw error
54
+ if (knownShape || !REPLAY_SAFE_OPERATIONS.has(operation) || !isArgumentShapeError(error)) {
55
+ throw error
56
+ }
50
57
  const fallbackShape = firstShape === "flat" ? "legacy" : "flat"
51
58
  const fallbackInput = fallbackShape === "flat" ? flatInput : legacyInput
52
59
  const response = await method.call(client.session, fallbackInput)
@@ -91,6 +98,9 @@ export function createOpenCodeSessionApi(client, options = {}) {
91
98
  get(sessionID) {
92
99
  return invoke("get", { sessionID }, { path: { id: sessionID } })
93
100
  },
101
+ delete(sessionID) {
102
+ return invoke("delete", { sessionID }, { path: { id: sessionID } })
103
+ },
94
104
  abort(sessionID) {
95
105
  return invoke("abort", { sessionID }, { path: { id: sessionID } })
96
106
  },
@@ -27,7 +27,10 @@ async function readOwner(lockPath) {
27
27
  * deliberately rejects a second writer instead of allowing stale full-state
28
28
  * snapshots to overwrite each other.
29
29
  */
30
- export async function acquirePersistenceLease(stateFilePath) {
30
+ export async function acquirePersistenceLease(
31
+ stateFilePath,
32
+ { malformedGraceMs = 30_000, now = () => Date.now() } = {},
33
+ ) {
31
34
  const lockPath = `${stateFilePath}.lock`
32
35
  await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
33
36
  const owner = {
@@ -58,7 +61,16 @@ export async function acquirePersistenceLease(stateFilePath) {
58
61
  }
59
62
  const existing = await readOwner(lockPath)
60
63
  const sameHost = existing?.hostname === owner.hostname
61
- if (sameHost && processIsAlive(existing?.pid) === false) {
64
+ let reclaimableMalformed = false
65
+ if (!existing) {
66
+ try {
67
+ const info = await fs.lstat(lockPath)
68
+ reclaimableMalformed = now() - info.mtimeMs >= malformedGraceMs
69
+ } catch (statError) {
70
+ if (statError?.code === "ENOENT") continue
71
+ }
72
+ }
73
+ if ((sameHost && processIsAlive(existing?.pid) === false) || reclaimableMalformed) {
62
74
  const stalePath = `${lockPath}.stale.${randomUUID()}`
63
75
  try {
64
76
  await fs.rename(lockPath, stalePath)
@@ -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
@@ -1,160 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { execFileSync } from "node:child_process"
3
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
4
- import { tmpdir } from "node:os"
5
- import { join } from "node:path"
6
- import { pathToFileURL } from "node:url"
7
-
8
- const repository = new URL("..", import.meta.url)
9
- const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-packed-host-"))
10
- const packDirectory = join(root, "pack")
11
- const projectDirectory = join(root, "host-project")
12
- const cacheDirectory = join(root, "npm-cache")
13
- const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory }
14
-
15
- try {
16
- await Promise.all([
17
- mkdir(packDirectory, { recursive: true }),
18
- mkdir(projectDirectory, { recursive: true }),
19
- mkdir(cacheDirectory, { recursive: true }),
20
- ])
21
- await writeFile(
22
- join(projectDirectory, "package.json"),
23
- JSON.stringify({ private: true, type: "module" }),
24
- )
25
-
26
- const packResult = JSON.parse(
27
- execFileSync(
28
- "npm",
29
- ["pack", "--json", "--pack-destination", packDirectory],
30
- { cwd: repository, encoding: "utf8", env: npmEnvironment },
31
- ),
32
- )
33
- assert.equal(packResult.length, 1)
34
- const tarball = join(packDirectory, packResult[0].filename)
35
-
36
- // Install only the artifact npm produced. The optional peer is omitted so this
37
- // contract test is offline-safe and cannot mutate the user's OpenCode install.
38
- execFileSync(
39
- "npm",
40
- [
41
- "install",
42
- "--ignore-scripts",
43
- "--no-audit",
44
- "--no-fund",
45
- "--no-package-lock",
46
- "--omit=peer",
47
- "--offline",
48
- "--cache",
49
- cacheDirectory,
50
- tarball,
51
- ],
52
- { cwd: projectDirectory, encoding: "utf8", env: npmEnvironment },
53
- )
54
-
55
- const installedManifestPath = join(
56
- projectDirectory,
57
- "node_modules",
58
- "opencode-goal-plugin",
59
- "package.json",
60
- )
61
- const installedManifest = JSON.parse(await readFile(installedManifestPath, "utf8"))
62
- const installedEntry = join(
63
- projectDirectory,
64
- "node_modules",
65
- "opencode-goal-plugin",
66
- installedManifest.main,
67
- )
68
- const installed = await import(pathToFileURL(installedEntry).href)
69
-
70
- assert.equal(installed.default.id, "opencode-goal-plugin")
71
- assert.equal(installed.default.server, installed.GoalPlugin)
72
-
73
- const sessionID = "packed-host-contract"
74
- const promptCalls = []
75
- const client = {
76
- app: { log: async () => {} },
77
- session: {
78
- messages: async ({ path }) => ({
79
- data: [
80
- {
81
- info: {
82
- id: "assistant-packed-contract",
83
- role: "assistant",
84
- sessionID: path.id,
85
- tokens: { input: 1, output: 1, reasoning: 0 },
86
- },
87
- parts: [{ type: "text", text: "Work remains." }],
88
- },
89
- ],
90
- }),
91
- promptAsync: async (input) => {
92
- promptCalls.push(input)
93
- return {}
94
- },
95
- },
96
- }
97
- const hooks = await installed.GoalPlugin(
98
- { client, directory: projectDirectory },
99
- {
100
- persistState: false,
101
- registerTools: false,
102
- minDelayMs: 1,
103
- noToolCallTurnsBeforePause: 10,
104
- },
105
- )
106
-
107
- for (const hook of [
108
- "config",
109
- "command.execute.before",
110
- "event",
111
- "experimental.chat.system.transform",
112
- "experimental.session.compacting",
113
- "experimental.compaction.autocontinue",
114
- "dispose",
115
- ]) {
116
- assert.equal(typeof hooks[hook], "function", `${hook} must be callable`)
117
- }
118
- const config = {}
119
- await hooks.config(config)
120
- assert.equal(config.agent.goal.mode, "primary")
121
- assert.equal(config.agent["goal-verify"].tools.edit, false)
122
-
123
- const output = { parts: [] }
124
- await hooks["command.execute.before"](
125
- { command: "goal", sessionID, arguments: "verify the installed artifact --max-turns 1" },
126
- output,
127
- )
128
- assert.match(output.parts[0]?.text, /New active goal/)
129
-
130
- // Let the configured throttle window elapse before idle. This avoids leaving
131
- // the contract dependent on the host's event-loop/timer shutdown behavior.
132
- await new Promise((resolve) => setTimeout(resolve, 5))
133
-
134
- await hooks.event({
135
- event: {
136
- type: "session.status",
137
- properties: { sessionID, status: { type: "idle" } },
138
- },
139
- })
140
-
141
- assert.equal(promptCalls.length, 1)
142
- // PluginInput currently supplies OpenCode's generated legacy client shape:
143
- // session.promptAsync({ path, body }). The standalone adapter suite covers
144
- // flattened v2 clients separately.
145
- assert.deepEqual(promptCalls[0].path, { id: sessionID })
146
- assert.equal(promptCalls[0].body.parts.length, 1)
147
- assert.deepEqual(promptCalls[0].body.parts[0].metadata, {
148
- "opencode-goal-plugin": { kind: "continuation" },
149
- })
150
- assert.equal(promptCalls[0].body.parts[0].synthetic, true)
151
-
152
- await hooks.dispose()
153
- await hooks.dispose()
154
-
155
- console.log(
156
- `packed host contract passed (${installedManifest.name}@${installedManifest.version}; ${packResult[0].size} byte tarball)`,
157
- )
158
- } finally {
159
- await rm(root, { recursive: true, force: true })
160
- }
@@ -1,51 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import pluginModule, { GoalPlugin } from "opencode-goal-plugin"
3
-
4
- const sessionID = `smoke-${Date.now()}`
5
- const promptCalls = []
6
- const logCalls = []
7
-
8
- const client = {
9
- app: {
10
- log: async (input) => {
11
- logCalls.push(input)
12
- },
13
- },
14
- session: {
15
- messages: async () => ({ data: [] }),
16
- promptAsync: async (input) => {
17
- promptCalls.push(input)
18
- return {}
19
- },
20
- },
21
- }
22
-
23
- assert.equal(pluginModule.id, "opencode-goal-plugin")
24
- assert.equal(pluginModule.server, GoalPlugin)
25
-
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 })
29
- assert.equal(typeof hooks["command.execute.before"], "function")
30
- assert.equal(typeof hooks.event, "function")
31
- assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
32
-
33
- const commandHook = hooks["command.execute.before"]
34
-
35
- async function runGoalCommand(args) {
36
- const output = { parts: [] }
37
- await commandHook({ command: "goal", sessionID, arguments: args }, output)
38
- assert.equal(output.parts.length, 1)
39
- assert.equal(output.parts[0].type, "text")
40
- return output.parts[0].text
41
- }
42
-
43
- assert.match(await runGoalCommand("status"), /No active goal/)
44
- assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
45
- assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
46
- assert.match(await runGoalCommand("clear"), /Goal cleared/)
47
- assert.match(await runGoalCommand("status"), /No active goal/)
48
- assert.equal(promptCalls.length, 0)
49
- assert.equal(logCalls.length, 0)
50
-
51
- console.log("opencode-goal-plugin command hook smoke passed")