@bybrawe/opencode-loop 0.5.27 → 0.5.29

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import OpenCodeLoopV2ExperimentalPlugin from "../src/source/opencode2/experimental.js"
6
+ import { createOpenCode2RuntimeAdapter } from "../src/source/opencode2/runtime-adapter.js"
7
+
8
+ function controllableStream() {
9
+ const queued = []
10
+ const waiting = []
11
+ let closed = false
12
+ const iterator = {
13
+ next() {
14
+ if (queued.length) return Promise.resolve({ done: false, value: queued.shift() })
15
+ if (closed) return Promise.resolve({ done: true, value: undefined })
16
+ return new Promise((resolve) => waiting.push(resolve))
17
+ },
18
+ async return() {
19
+ closed = true
20
+ while (waiting.length) waiting.shift()({ done: true, value: undefined })
21
+ return { done: true, value: undefined }
22
+ },
23
+ }
24
+ return {
25
+ stream: { [Symbol.asyncIterator]: () => iterator },
26
+ push(value) {
27
+ if (waiting.length) waiting.shift()({ done: false, value })
28
+ else queued.push(value)
29
+ },
30
+ }
31
+ }
32
+
33
+ async function waitFor(predicate, description, timeoutMs = 2_000) {
34
+ const deadline = Date.now() + timeoutMs
35
+ while (Date.now() < deadline) {
36
+ if (await predicate()) return
37
+ await new Promise((resolve) => setTimeout(resolve, 10))
38
+ }
39
+ throw new Error(`timed out waiting for ${description}`)
40
+ }
41
+
42
+ {
43
+ const events = controllableStream()
44
+ const commands = []
45
+ const ctx = {
46
+ event: { subscribe: () => events.stream },
47
+ session: {
48
+ prompt: async () => ({ accepted: true }),
49
+ command: async (request) => {
50
+ commands.push(structuredClone(request))
51
+ return { accepted: true }
52
+ },
53
+ },
54
+ }
55
+ const adapter = createOpenCode2RuntimeAdapter(ctx)
56
+ await adapter.start()
57
+ const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
58
+ assert.deepEqual(result, { accepted: true })
59
+ assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
60
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
61
+ await adapter.dispose()
62
+ }
63
+
64
+ {
65
+ const events = controllableStream()
66
+ const adapter = createOpenCode2RuntimeAdapter({
67
+ event: { subscribe: () => events.stream },
68
+ session: { prompt: async () => ({ accepted: true }) },
69
+ })
70
+ await adapter.start()
71
+ await assert.rejects(adapter.command({ sessionID: "ses_adapter", command: "review" }), /session\.command capability is unavailable/)
72
+ await adapter.dispose()
73
+ }
74
+
75
+ {
76
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-command-wiring-"))
77
+ const sessionID = "ses_v2_command_wiring"
78
+ const events = controllableStream()
79
+ const commands = []
80
+ const ctx = {
81
+ command: { transform: async () => ({ dispose: async () => {} }) },
82
+ event: { subscribe: () => events.stream },
83
+ session: {
84
+ prompt: async () => ({ accepted: true }),
85
+ command: async (request) => {
86
+ commands.push(structuredClone(request))
87
+ return { accepted: true }
88
+ },
89
+ },
90
+ }
91
+ const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
92
+ try {
93
+ events.push({
94
+ directory,
95
+ payload: {
96
+ type: "command.executed",
97
+ properties: {
98
+ sessionID,
99
+ name: "loop",
100
+ arguments: '0s --max-runs 1 --command "/review --quick"',
101
+ },
102
+ },
103
+ })
104
+ events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
105
+ await waitFor(() => commands.length === 1, "V2 command dispatch")
106
+ assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" })
107
+ } finally {
108
+ await cleanup?.()
109
+ await rm(directory, { recursive: true, force: true })
110
+ }
111
+ }
112
+
113
+ console.log("OpenCode 2 command adapter tests passed")
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { createOpenCode2PromptRuntime } from "../src/source/opencode2/prompt-runtime.js"
6
+
7
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-command-"))
8
+ const sessionID = "ses_v2_command_runtime"
9
+ const prompts = []
10
+ const commands = []
11
+ const runtime = createOpenCode2PromptRuntime({
12
+ prompt: async (request) => { prompts.push(request) },
13
+ command: async (request) => { commands.push(request); return { accepted: true } },
14
+ })
15
+
16
+ async function readState() {
17
+ const file = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
18
+ return JSON.parse(await readFile(file, "utf8"))
19
+ }
20
+
21
+ try {
22
+ const added = await runtime.onEvent({
23
+ kind: "command",
24
+ action: "executed",
25
+ name: "loop",
26
+ sessionID,
27
+ directory,
28
+ arguments: '0s --max-runs 2 --command "/review --quick"',
29
+ })
30
+ assert.equal(added.accepted, true)
31
+ assert.equal(added.job.kind, "command")
32
+ assert.equal(added.job.action, "/review --quick")
33
+
34
+ const first = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
35
+ assert.equal(first.dispatched, true)
36
+ assert.equal(first.kind, "command")
37
+ assert.deepEqual(first.request, { sessionID, command: "review", arguments: "--quick" })
38
+ assert.deepEqual(commands, [{ sessionID, command: "review", arguments: "--quick" }])
39
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false, "OpenCode 2 id is a msg_ message ID, never the command name")
40
+ assert.equal(prompts.length, 0)
41
+ let state = await readState()
42
+ assert.equal(state.jobs[0].runCount, 1)
43
+ assert.equal(state.jobs[0].enabled, true)
44
+
45
+ const second = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
46
+ assert.equal(second.dispatched, true)
47
+ assert.deepEqual(commands[1], { sessionID, command: "review", arguments: "--quick" })
48
+ state = await readState()
49
+ assert.equal(state.jobs[0].runCount, 2)
50
+ assert.equal(state.jobs[0].enabled, false)
51
+
52
+ const third = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
53
+ assert.equal(third.dispatched, false)
54
+ assert.equal(commands.length, 2)
55
+
56
+ const compact = await runtime.onEvent({
57
+ kind: "command",
58
+ action: "executed",
59
+ name: "loop",
60
+ sessionID,
61
+ directory,
62
+ arguments: '0s --command "/compact"',
63
+ })
64
+ assert.equal(compact.accepted, false)
65
+ assert.ok(compact.blockers.includes("kind"), "shared actionKind keeps /compact on the dedicated compact path")
66
+
67
+ const shell = await runtime.onEvent({
68
+ kind: "command",
69
+ action: "executed",
70
+ name: "loop",
71
+ sessionID,
72
+ directory,
73
+ arguments: '0s --shell "echo hello"',
74
+ })
75
+ assert.equal(shell.accepted, false)
76
+ assert.ok(shell.blockers.includes("kind"), "shell actions remain unsupported because OpenCode 2 plugin ctx has no session.shell")
77
+
78
+ const noCommandRuntime = createOpenCode2PromptRuntime({ prompt: async () => {} })
79
+ try {
80
+ const unavailable = await noCommandRuntime.onEvent({
81
+ kind: "command",
82
+ action: "executed",
83
+ name: "loop",
84
+ sessionID: "ses_no_command",
85
+ directory,
86
+ arguments: '0s --command "/review"',
87
+ })
88
+ assert.equal(unavailable.accepted, false)
89
+ assert.ok(unavailable.blockers.includes("command-capability"))
90
+ } finally {
91
+ await noCommandRuntime.dispose()
92
+ }
93
+
94
+ console.log("OpenCode 2 command runtime tests passed")
95
+ } finally {
96
+ await runtime.dispose()
97
+ await rm(directory, { recursive: true, force: true })
98
+ }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const seen = []
5
+ let listener
6
+ let unsubscribeCalls = 0
7
+
8
+ const bridge = createOpenCode2EventBridge({
9
+ directory: "/project",
10
+ onEvent: async (event, runtime) => seen.push({ event, runtime }),
11
+ })
12
+
13
+ await bridge.attach(async (callback) => {
14
+ listener = callback
15
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
16
+ })
17
+
18
+ await listener({
19
+ directory: "/project",
20
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
21
+ })
22
+ const first = bridge.runtimeManager.peek("ses_a")
23
+ assert.ok(first)
24
+ assert.equal(first.scope.isActive(), true)
25
+
26
+ await listener({
27
+ directory: "/project",
28
+ payload: { type: "session.status", properties: { sessionID: "ses_a", status: { type: "busy" } } },
29
+ })
30
+ assert.equal(bridge.runtimeManager.peek("ses_a"), first)
31
+
32
+ await listener({
33
+ directory: "/project",
34
+ payload: { type: "session.created", properties: { info: { id: "ses_b", directory: "/project" } } },
35
+ })
36
+ const second = bridge.runtimeManager.peek("ses_b")
37
+ assert.ok(second)
38
+ assert.notEqual(second, first)
39
+
40
+ await listener({
41
+ directory: "/other-project",
42
+ payload: { type: "session.status", properties: { sessionID: "ses_other", status: { type: "busy" } } },
43
+ })
44
+ assert.equal(bridge.runtimeManager.peek("ses_other"), undefined)
45
+
46
+ await listener({
47
+ directory: "/project",
48
+ payload: { type: "session.deleted", properties: { info: { id: "ses_a", directory: "/project" } } },
49
+ })
50
+ assert.equal(bridge.runtimeManager.peek("ses_a"), undefined)
51
+ assert.equal(first.scope.isActive(), false)
52
+ assert.equal(second.scope.isActive(), true)
53
+
54
+ await listener({
55
+ directory: "/project",
56
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
57
+ })
58
+ assert.equal(second.scope.isActive(), false)
59
+ assert.deepEqual(bridge.runtimeManager.entries(), [])
60
+
61
+ const seenCount = seen.length
62
+ await listener({
63
+ directory: "/project",
64
+ payload: { type: "session.status", properties: { sessionID: "ses_late", status: { type: "busy" } } },
65
+ })
66
+ assert.equal(seen.length, seenCount)
67
+
68
+ assert.equal(await bridge.dispose(), true)
69
+ assert.equal(await bridge.dispose(), false)
70
+ assert.equal(unsubscribeCalls, 1)
71
+
72
+ console.log("OpenCode 2 event bridge lifecycle passed")
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
5
+ const seen = []
6
+ let subscribeArgs = -1
7
+ let cleanupCalls = 0
8
+
9
+ async function* stream() {
10
+ yield {
11
+ directory: "/project",
12
+ payload: {
13
+ type: "session.created",
14
+ properties: { info: { id: "ses_stream", directory: "/project" } },
15
+ },
16
+ }
17
+ }
18
+
19
+ const bridge = createOpenCode2EventBridge({
20
+ directory: "/project",
21
+ onEvent: async (event) => seen.push(event),
22
+ })
23
+
24
+ await bridge.attach(async (...args) => {
25
+ subscribeArgs = args.length
26
+ return { stream: stream(), dispose: async () => { cleanupCalls += 1 } }
27
+ })
28
+
29
+ for (let attempt = 0; attempt < 50 && seen.length === 0; attempt++) await delay(5)
30
+ assert.equal(subscribeArgs, 0)
31
+ assert.equal(seen[0]?.action, "created")
32
+ assert.ok(bridge.runtimeManager.peek("ses_stream"))
33
+
34
+ assert.equal(await bridge.dispose(), true)
35
+ assert.equal(cleanupCalls, 1)
36
+ assert.equal(bridge.runtimeManager.peek("ses_stream"), undefined)
37
+
38
+ console.log("OpenCode 2 event stream contract passed")
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js"
3
+
4
+ let listener
5
+ let unsubscribeCalls = 0
6
+ const prompts = []
7
+ const commands = []
8
+ const events = []
9
+ const host = createOpenCode2HostContract({
10
+ directory: "/project",
11
+ subscribe: async (callback) => {
12
+ listener = callback
13
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
14
+ },
15
+ sendPrompt: async (request) => {
16
+ prompts.push(request)
17
+ return { accepted: true }
18
+ },
19
+ sendCommand: async (request) => {
20
+ commands.push(request)
21
+ return { accepted: true, kind: "command" }
22
+ },
23
+ onEvent: async (event, runtime) => events.push({ event, runtime }),
24
+ })
25
+
26
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/)
27
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /not started/)
28
+ assert.equal(await host.start(), true)
29
+ assert.equal(await host.start(), false)
30
+ assert.equal(typeof listener, "function")
31
+
32
+ await listener({
33
+ directory: "/project",
34
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
35
+ })
36
+ const runtime = host.runtimeManager.peek("ses_a")
37
+ assert.ok(runtime)
38
+
39
+ const result = await host.prompt({ sessionID: "ses_a", text: "continue" })
40
+ assert.deepEqual(result, { accepted: true })
41
+ assert.equal(prompts.length, 1)
42
+ assert.equal(prompts[0].sessionID, "ses_a")
43
+ assert.equal(prompts[0].text, "continue")
44
+ assert.equal(prompts[0].runtime, runtime)
45
+
46
+ const commandResult = await host.command({ sessionID: "ses_a", command: "/review", arguments: "--quick" })
47
+ assert.deepEqual(commandResult, { accepted: true, kind: "command" })
48
+ assert.equal(commands.length, 1)
49
+ assert.equal(commands[0].sessionID, "ses_a")
50
+ assert.equal(commands[0].command, "review")
51
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
52
+ assert.equal(commands[0].arguments, "--quick")
53
+ assert.equal(commands[0].runtime, runtime)
54
+
55
+ await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/)
56
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/)
57
+ await assert.rejects(host.command({ sessionID: "", command: "review" }), /session ID/)
58
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "" }), /requires a command name/)
59
+ await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /requires a command name/)
60
+
61
+ const promptOnly = createOpenCode2HostContract({
62
+ subscribe: async (_callback) => ({ unsubscribe: async () => {} }),
63
+ sendPrompt: async () => ({ accepted: true }),
64
+ })
65
+ await promptOnly.start()
66
+ await assert.rejects(promptOnly.command({ sessionID: "ses_a", command: "review" }), /session\.command capability is unavailable/)
67
+ await promptOnly.dispose()
68
+
69
+ await listener({
70
+ directory: "/project",
71
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
72
+ })
73
+ assert.equal(host.isHostDisposed(), true)
74
+ assert.deepEqual(host.runtimeManager.entries(), [])
75
+ assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed"))
76
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/)
77
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /unavailable/)
78
+
79
+ assert.equal(await host.dispose(), true)
80
+ assert.equal(await host.dispose(), false)
81
+ assert.equal(unsubscribeCalls, 1)
82
+ console.log("OpenCode 2 host contract passed")
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict"
2
+ import plugin, {
3
+ OPENCODE_LOOP_V2_COMMANDS,
4
+ OPENCODE_LOOP_V2_PLUGIN_ID,
5
+ } from "../src/source/opencode2/experimental.js"
6
+ import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
7
+ import {
8
+ OPENCODE_LOOP_V2_COMMAND_SOURCE,
9
+ OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
10
+ OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED,
11
+ OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS,
12
+ inspectOpenCode2CommandDraft,
13
+ inspectOpenCode2Context,
14
+ openCode2LoopRuntimeStatus,
15
+ } from "../src/source/opencode2/capabilities.js"
16
+
17
+ assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID)
18
+ assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental")
19
+ assert.equal(typeof plugin.setup, "function")
20
+ assert.equal(OPENCODE_LOOP_V2_COMMAND_SOURCE, "plugin-transform")
21
+ assert.equal(OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED, false)
22
+ assert.deepEqual(OPENCODE_LOOP_V2_HOST_REQUIREMENTS, ["event.subscribe", "session.prompt"])
23
+ assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["event.subscribe", "session.prompt", "runtime.adapter"])
24
+ assert.deepEqual(Object.keys(OPENCODE_LOOP_V2_COMMANDS), [
25
+ "loop",
26
+ "loop-pause",
27
+ "loop-resume",
28
+ "loop-stop",
29
+ "loop-remove",
30
+ "loop-clear",
31
+ "loop-status",
32
+ "loop-now",
33
+ ])
34
+ assert.deepEqual(
35
+ parseOpenCode2LoopCommandText(`${OPENCODE_LOOP_V2_COMMANDS["loop-now"].template}\n\nsecond`),
36
+ { name: "loop-now", arguments: "second" },
37
+ )
38
+
39
+ let transforms = 0
40
+ let registered
41
+ const currentContext = {
42
+ command: {
43
+ transform: async (callback) => {
44
+ transforms += 1
45
+ registered = callback
46
+ },
47
+ },
48
+ }
49
+ const setupResult = await plugin.setup(currentContext)
50
+ assert.equal(setupResult, undefined)
51
+ assert.equal(transforms, 1)
52
+ assert.equal(typeof registered, "function")
53
+
54
+ const commands = new Map()
55
+ const currentDraft = {
56
+ list: () => [...commands.values()],
57
+ get: (name) => commands.get(name),
58
+ update: (name, update) => {
59
+ const command = commands.get(name) ?? { name, template: "" }
60
+ update(command)
61
+ command.name = name
62
+ commands.set(name, command)
63
+ },
64
+ remove: (name) => commands.delete(name),
65
+ }
66
+ await registered(currentDraft)
67
+
68
+ assert.deepEqual([...commands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
69
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
70
+ const command = commands.get(name)
71
+ assert.equal(command?.name, name)
72
+ assert.equal(command?.template, definition.template)
73
+ assert.equal(command?.description, definition.description)
74
+ }
75
+
76
+ let missingDraftUpdateFailed = false
77
+ try {
78
+ await registered({})
79
+ } catch (error) {
80
+ missingDraftUpdateFailed = /command draft\.update capability is unavailable/.test(String(error))
81
+ }
82
+ assert.equal(missingDraftUpdateFailed, true)
83
+
84
+ assert.deepEqual(inspectOpenCode2Context(currentContext), {
85
+ commandTransform: true,
86
+ eventSubscribe: false,
87
+ sessionHook: false,
88
+ sessionPrompt: false,
89
+ sessionCommand: false,
90
+ sessionShell: false,
91
+ toolTransform: false,
92
+ toolHook: false,
93
+ })
94
+ assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
95
+ list: true,
96
+ get: true,
97
+ update: true,
98
+ remove: true,
99
+ })
100
+
101
+ const currentStatus = openCode2LoopRuntimeStatus(currentContext, currentDraft)
102
+ assert.deepEqual(currentStatus.hostBlockers, ["event.subscribe", "session.prompt"])
103
+ assert.deepEqual(currentStatus.blockers, ["event.subscribe", "session.prompt", "runtime.adapter"])
104
+ assert.equal(currentStatus.hostReady, false)
105
+ assert.equal(currentStatus.implementationReady, false)
106
+ assert.equal(currentStatus.ready, false)
107
+ assert.equal(currentStatus.commandSource, "plugin-transform")
108
+
109
+ const futureContext = {
110
+ command: { transform: async () => {} },
111
+ event: { subscribe: async () => {} },
112
+ session: {
113
+ prompt: async () => ({}),
114
+ command: async () => ({}),
115
+ },
116
+ }
117
+ const futureCapabilities = inspectOpenCode2Context(futureContext)
118
+ assert.equal(futureCapabilities.sessionCommand, true)
119
+ assert.equal(futureCapabilities.sessionShell, false)
120
+ const futureStatus = openCode2LoopRuntimeStatus(futureContext, currentDraft)
121
+ assert.deepEqual(futureStatus.hostBlockers, [])
122
+ assert.deepEqual(futureStatus.blockers, ["runtime.adapter"])
123
+ assert.equal(futureStatus.hostReady, true)
124
+ assert.equal(futureStatus.implementationReady, false)
125
+ assert.equal(futureStatus.ready, false)
126
+ assert.equal(futureStatus.commandSource, "plugin-transform")
127
+
128
+ let missingCapabilityFailed = false
129
+ try {
130
+ await plugin.setup({})
131
+ } catch (error) {
132
+ missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error))
133
+ }
134
+ assert.equal(missingCapabilityFailed, true)
135
+
136
+ console.log("OpenCode 2 plugin sentinel contract passed")