opencode-goal-plugin 0.5.0 → 0.6.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/index.d.ts CHANGED
@@ -31,10 +31,19 @@ export interface CompletionAuditContext {
31
31
  export interface CompletionAuditorOptions {
32
32
  /**
33
33
  * How long, in milliseconds, the built-in auditor waits for a verdict from
34
- * its child OpenCode session before failing open (auto-approving).
34
+ * its child OpenCode session. A timeout rejects the audit and pauses the
35
+ * goal. Operational failures follow {@link failurePolicy}.
35
36
  * @default 120000
36
37
  */
37
38
  timeoutMs?: number
39
+
40
+ /**
41
+ * Result used when the child-session API is unavailable, malformed, throws,
42
+ * or times out. Semantic rejection or an invalid verdict always rejects.
43
+ * `"approve"` is an explicit compatibility escape hatch.
44
+ * @default "reject"
45
+ */
46
+ failurePolicy?: "reject" | "approve"
38
47
  }
39
48
 
40
49
  /**
@@ -45,6 +54,14 @@ export interface CompletionAuditorOptions {
45
54
  * flags (e.g. `--max-turns`, `--success`, `--mode`).
46
55
  */
47
56
  export interface GoalPluginOptions {
57
+ /**
58
+ * OpenCode session SDK argument shape. PluginInput currently supplies the
59
+ * legacy generated client; set `"flat"` when embedding with the v2 SDK.
60
+ * The compatibility adapter remembers the successful shape per operation.
61
+ * @default "legacy"
62
+ */
63
+ sdkShape?: "legacy" | "flat"
64
+
48
65
  /**
49
66
  * Maximum number of auto-continue turns sent toward a goal before it is
50
67
  * stopped for exceeding limits. Overridable per-goal with `--max-turns`.
@@ -104,7 +121,7 @@ export interface GoalPluginOptions {
104
121
  * Grace window for tool-free continuation turns (a "talk only" turn that
105
122
  * calls no tool). Complements the no-progress check by catching
106
123
  * self-chat loops that still produce output. Overridable per-goal with
107
- * `--no-tool-turns`.
124
+ * `--no-tool-turns`. Set the plugin option to `0` to disable this heuristic.
108
125
  * @default 2
109
126
  */
110
127
  noToolCallTurnsBeforePause?: number
@@ -172,6 +189,12 @@ export interface GoalPluginOptions {
172
189
  */
173
190
  ledgerFilePath?: string
174
191
 
192
+ /** Maximum bytes in one lifecycle-ledger generation. @default 2097152 */
193
+ ledgerMaxBytes?: number
194
+
195
+ /** Number of rotated lifecycle-ledger generations to retain (0-10). @default 3 */
196
+ ledgerRetentionFiles?: number
197
+
175
198
  /**
176
199
  * How long, in milliseconds, a completed goal's summary remains
177
200
  * available through `/goal status` after the goal leaves active memory.
@@ -206,14 +229,25 @@ export interface GoalPluginOptions {
206
229
 
207
230
  /**
208
231
  * Whether the plugin registers the agent-facing goal tools
209
- * (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`,
210
- * `clear_goal`). Requires the optional `@opencode-ai/plugin` peer
232
+ * (canonical `goal_status`, `goal_set`, `goal_pause`, `goal_resume`,
233
+ * `goal_block`, `goal_complete`, plus legacy `get_goal`,
234
+ * `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`).
235
+ * Canonical tools return versioned JSON envelopes. Requires the optional `@opencode-ai/plugin` peer
211
236
  * dependency; when it is absent, tool registration is silently skipped
212
237
  * and the command/event hooks still work.
213
238
  * @default true
214
239
  */
215
240
  registerTools?: boolean
216
241
 
242
+ /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
243
+ registerAgents?: boolean
244
+
245
+ /** Name of the native primary goal agent. @default "goal" */
246
+ goalAgentName?: string
247
+
248
+ /** Name of the native read-only verifier subagent. @default "goal-verify" */
249
+ verifierAgentName?: string
250
+
217
251
  /**
218
252
  * Enables the built-in child-session completion auditor: before a
219
253
  * `[goal:complete]` is archived, the plugin spawns an independent
@@ -262,17 +296,22 @@ export interface GoalPluginOptions {
262
296
  * not by this package.
263
297
  */
264
298
  export interface GoalPluginHooks {
299
+ /** Registers collision-safe native goal and verifier agents. */
300
+ config: (config: unknown) => Promise<void>
265
301
  /** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */
266
302
  "command.execute.before"?: (input: unknown, output: unknown) => Promise<void>
267
303
  event: (input: unknown) => Promise<void>
268
304
  "experimental.chat.system.transform": (input: unknown, output: unknown) => Promise<void>
269
305
  "experimental.compaction.autocontinue": (input: unknown, output: unknown) => Promise<void>
306
+ "experimental.session.compacting": (input: unknown, output: unknown) => Promise<void>
270
307
  /**
271
308
  * Agent-facing tool definitions, present only when
272
309
  * {@link GoalPluginOptions.registerTools} is enabled (default) and the
273
310
  * optional `@opencode-ai/plugin` peer dependency is installed.
274
311
  */
275
312
  tool?: Record<string, unknown>
313
+ /** Cancels pending continuation work and releases this plugin instance. */
314
+ dispose: () => Promise<void>
276
315
  [hook: string]: unknown
277
316
  }
278
317
 
@@ -282,10 +321,19 @@ export interface GoalPluginHooks {
282
321
  * `opencode.json`.
283
322
  */
284
323
  export function GoalPlugin(
285
- context: { client: unknown },
324
+ context: {
325
+ client: unknown
326
+ /** OpenCode's resolved project directory for this plugin instance. */
327
+ directory?: string
328
+ /** OpenCode's resolved worktree directory for this plugin instance. */
329
+ worktree?: string
330
+ },
286
331
  options?: GoalPluginOptions,
287
332
  ): Promise<GoalPluginHooks>
288
333
 
334
+ /** Internal diagnostic/test helpers. Not covered by semantic-version compatibility guarantees. */
335
+ export const testInternals: Readonly<Record<string, unknown>>
336
+
289
337
  /**
290
338
  * Default export consumed by OpenCode's plugin loader:
291
339
  * `{ "opencode-goal-plugin": { ... } }` in `opencode.json` resolves `id`
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.5.0",
4
- "description": "Session-scoped /goal workflow for OpenCode.",
3
+ "version": "0.6.1",
4
+ "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
7
7
  "types": "./index.d.ts",
8
8
  "exports": {
9
- ".": "./src/goal-plugin.js",
10
- "./server": "./src/goal-plugin.js"
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./src/goal-plugin.js",
12
+ "default": "./src/goal-plugin.js"
13
+ },
14
+ "./server": {
15
+ "types": "./index.d.ts",
16
+ "import": "./src/goal-plugin.js",
17
+ "default": "./src/goal-plugin.js"
18
+ }
11
19
  },
12
20
  "bin": {
13
21
  "opencode-goal-plugin": "./scripts/verify.mjs"
@@ -16,6 +24,8 @@
16
24
  "src",
17
25
  "scripts",
18
26
  "examples",
27
+ "demo",
28
+ "docs",
19
29
  "index.d.ts",
20
30
  "README.md",
21
31
  "CHANGELOG.md",
@@ -28,6 +38,8 @@
28
38
  "test": "node --test test/*.test.js",
29
39
  "test:coverage": "node --test --experimental-test-coverage test/*.test.js",
30
40
  "smoke": "node scripts/smoke-command-hook.mjs",
41
+ "smoke:packed-host": "node scripts/packed-host-contract.mjs",
42
+ "benchmark:behavior": "node scripts/behavior-benchmark.mjs",
31
43
  "verify": "node scripts/verify.mjs",
32
44
  "check": "node -c src/goal-plugin.js && npm test",
33
45
  "pack:check": "npm pack --dry-run"
@@ -39,7 +51,7 @@
39
51
  "goal"
40
52
  ],
41
53
  "peerDependencies": {
42
- "@opencode-ai/plugin": ">=1"
54
+ "@opencode-ai/plugin": ">=1.17.15 <2"
43
55
  },
44
56
  "peerDependenciesMeta": {
45
57
  "@opencode-ai/plugin": {
@@ -48,7 +60,8 @@
48
60
  },
49
61
  "license": "MIT",
50
62
  "engines": {
51
- "node": ">=18"
63
+ "node": ">=18",
64
+ "opencode": ">=1.17.15 <2"
52
65
  },
53
66
  "repository": {
54
67
  "type": "git",
@@ -0,0 +1,272 @@
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
@@ -0,0 +1,160 @@
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
+ }
@@ -7,10 +7,12 @@
7
7
  import assert from "node:assert/strict"
8
8
 
9
9
  const REQUIRED_HOOKS = [
10
+ "config",
10
11
  "command.execute.before",
11
12
  "event",
12
13
  "experimental.chat.system.transform",
13
14
  "experimental.compaction.autocontinue",
15
+ "experimental.session.compacting",
14
16
  ]
15
17
 
16
18
  const results = []
@@ -68,10 +70,10 @@ const client = {
68
70
 
69
71
  let hooks
70
72
 
71
- await check("plugin initializes and registers all 4 required hooks", async () => {
73
+ await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} required hooks`, async () => {
72
74
  // registerTools defaults to true but silently no-ops without the optional
73
75
  // @opencode-ai/plugin peer dependency, so it is not asserted here — the
74
- // 4 hooks below are always present regardless of that peer dependency.
76
+ // These hooks are always present regardless of that peer dependency.
75
77
  hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
76
78
  for (const hookName of REQUIRED_HOOKS) {
77
79
  assert.equal(