@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,60 @@
1
+ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
2
+ loop: Object.freeze({
3
+ description: "Start an OpenCode auto-continue loop. Usage: /loop 5m <task>",
4
+ template: "OpenCode Loop local command handled. Reply exactly: OK.",
5
+ }),
6
+ "loop-pause": Object.freeze({
7
+ description: "Pause OpenCode Loop jobs.",
8
+ template: "OpenCode Loop pause command handled locally. Reply exactly: OK.",
9
+ }),
10
+ "loop-resume": Object.freeze({
11
+ description: "Resume OpenCode Loop jobs.",
12
+ template: "OpenCode Loop resume command handled locally. Reply exactly: OK.",
13
+ }),
14
+ "loop-stop": Object.freeze({
15
+ description: "Stop OpenCode Loop jobs.",
16
+ template: "OpenCode Loop stop command handled locally. Reply exactly: OK.",
17
+ }),
18
+ "loop-remove": Object.freeze({
19
+ description: "Remove OpenCode Loop jobs.",
20
+ template: "OpenCode Loop remove command handled locally. Reply exactly: OK.",
21
+ }),
22
+ "loop-clear": Object.freeze({
23
+ description: "Clear all OpenCode Loop jobs.",
24
+ template: "OpenCode Loop clear command handled locally. Reply exactly: OK.",
25
+ }),
26
+ "loop-status": Object.freeze({
27
+ description: "Show OpenCode Loop status for the current session.",
28
+ template: "OpenCode Loop status command handled locally. Reply exactly: OK.",
29
+ }),
30
+ "loop-now": Object.freeze({
31
+ description: "Run matching OpenCode Loop jobs on the next idle boundary.",
32
+ template: "OpenCode Loop run-now command handled locally. Reply exactly: OK.",
33
+ }),
34
+ })
35
+
36
+ export function registerOpenCode2LoopCommands(draft) {
37
+ if (!draft || typeof draft.update !== "function") {
38
+ throw new Error("OpenCode 2 command draft.update capability is unavailable")
39
+ }
40
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
41
+ draft.update(name, (command) => {
42
+ command.template = definition.template
43
+ command.description = definition.description
44
+ })
45
+ }
46
+ }
47
+
48
+ export function parseOpenCode2LoopCommandText(value) {
49
+ if (typeof value !== "string") return undefined
50
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
51
+ const template = definition.template
52
+ if (value === template) return Object.freeze({ name, arguments: "" })
53
+ if (!value.startsWith(`${template}\n\n`)) continue
54
+ return Object.freeze({
55
+ name,
56
+ arguments: value.slice(template.length + 2).trim(),
57
+ })
58
+ }
59
+ return undefined
60
+ }
@@ -0,0 +1,174 @@
1
+ import { normalizeOpenCodeEvent } from "../runtime/events.js"
2
+ import { createSessionRuntimeManager } from "../runtime/session-manager.js"
3
+ import { normalizeOpenCode2NativeEvent } from "./events.js"
4
+
5
+ function cleanupRegistration(registration) {
6
+ if (typeof registration === "function") return registration
7
+ if (!registration || typeof registration !== "object") return undefined
8
+ for (const key of ["dispose", "unsubscribe", "close"]) {
9
+ if (typeof registration[key] === "function") return registration[key].bind(registration)
10
+ }
11
+ return undefined
12
+ }
13
+
14
+ function eventStream(registration) {
15
+ const stream = registration?.stream ?? registration
16
+ if (!stream || typeof stream[Symbol.asyncIterator] !== "function") {
17
+ throw new TypeError("OpenCode 2 event subscribe must return an async event stream")
18
+ }
19
+ return stream
20
+ }
21
+
22
+ function sameDirectory(expected, actual) {
23
+ if (!expected || !actual) return true
24
+ return String(expected) === String(actual)
25
+ }
26
+
27
+ export function createOpenCode2EventBridge({
28
+ directory,
29
+ onEvent = async () => {},
30
+ onError = () => {},
31
+ runtimeManager = createSessionRuntimeManager(),
32
+ } = {}) {
33
+ if (typeof onEvent !== "function") throw new TypeError("OpenCode 2 event bridge requires onEvent to be a function")
34
+ if (typeof onError !== "function") throw new TypeError("OpenCode 2 event bridge requires onError to be a function")
35
+
36
+ let registration
37
+ let iterator
38
+ let iteratorClosed = false
39
+ let pump
40
+ let attached = false
41
+ let stopped = false
42
+ let disposed = false
43
+ let managerDisposed = false
44
+ let queue = Promise.resolve()
45
+ const sessionDirectories = new Map()
46
+
47
+ function report(error) {
48
+ try { onError(error) } catch {}
49
+ }
50
+
51
+ function disposeManager(reason) {
52
+ if (managerDisposed) return false
53
+ managerDisposed = true
54
+ return runtimeManager.dispose(reason)
55
+ }
56
+
57
+ async function closeIterator() {
58
+ if (iteratorClosed) return false
59
+ iteratorClosed = true
60
+ const current = iterator
61
+ iterator = undefined
62
+ if (typeof current?.return === "function") await current.return()
63
+ return Boolean(current)
64
+ }
65
+
66
+ function normalize(raw) {
67
+ const current = normalizeOpenCode2NativeEvent(raw) || normalizeOpenCodeEvent(raw)
68
+ if (!current) return undefined
69
+ const sessionID = current.sessionID
70
+ const rememberedDirectory = sessionID ? sessionDirectories.get(sessionID) : undefined
71
+ const eventDirectory = current.directory || rememberedDirectory || directory
72
+ const event = eventDirectory === current.directory
73
+ ? current
74
+ : Object.freeze({ ...current, directory: eventDirectory })
75
+ if (sessionID && event.directory) sessionDirectories.set(sessionID, event.directory)
76
+ return event
77
+ }
78
+
79
+ async function process(raw) {
80
+ if (stopped) return undefined
81
+ const event = normalize(raw)
82
+ if (!event || !sameDirectory(directory, event.directory)) return undefined
83
+
84
+ const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
85
+ await onEvent(event, runtime)
86
+
87
+ if (event.kind === "session" && event.action === "deleted") {
88
+ sessionDirectories.delete(event.sessionID)
89
+ runtimeManager.remove(event.sessionID, { expectedRuntime: runtime, reason: "session-deleted" })
90
+ }
91
+
92
+ if (event.kind === "server" && event.action === "disposed") {
93
+ stopped = true
94
+ sessionDirectories.clear()
95
+ disposeManager("server-disposed")
96
+ }
97
+
98
+ return event
99
+ }
100
+
101
+ function dispatch(raw) {
102
+ if (stopped) return Promise.resolve(undefined)
103
+ const result = queue.then(() => process(raw))
104
+ queue = result.catch(() => undefined)
105
+ return result
106
+ }
107
+
108
+ function callback(raw) {
109
+ const pending = dispatch(raw)
110
+ pending.catch(report)
111
+ return pending.catch(() => undefined)
112
+ }
113
+
114
+ async function consume(stream) {
115
+ const current = stream[Symbol.asyncIterator]()
116
+ iterator = current
117
+ iteratorClosed = false
118
+ try {
119
+ while (!stopped) {
120
+ const next = await current.next()
121
+ if (next?.done) break
122
+ await dispatch(next?.value)
123
+ }
124
+ } catch (error) {
125
+ if (!stopped) report(error)
126
+ } finally {
127
+ if (stopped && !iteratorClosed) await closeIterator().catch(() => undefined)
128
+ if (iterator === current) iterator = undefined
129
+ }
130
+ }
131
+
132
+ async function attach(subscribe) {
133
+ if (disposed) throw new Error("OpenCode 2 event bridge is disposed")
134
+ if (attached) throw new Error("OpenCode 2 event bridge is already attached")
135
+ if (typeof subscribe !== "function") throw new TypeError("OpenCode 2 event bridge requires an event subscribe function")
136
+
137
+ if (subscribe.length > 0) {
138
+ registration = await subscribe(callback)
139
+ attached = true
140
+ return registration
141
+ }
142
+
143
+ registration = await subscribe()
144
+ const stream = eventStream(registration)
145
+ attached = true
146
+ pump = consume(stream)
147
+ return registration
148
+ }
149
+
150
+ async function dispose(reason = "bridge-disposed") {
151
+ if (disposed) return false
152
+ disposed = true
153
+ stopped = true
154
+ await closeIterator().catch(() => undefined)
155
+ await pump?.catch(() => undefined)
156
+ await queue.catch(() => undefined)
157
+
158
+ const cleanup = cleanupRegistration(registration)
159
+ registration = undefined
160
+ if (cleanup) await cleanup()
161
+ sessionDirectories.clear()
162
+ disposeManager(reason)
163
+ return true
164
+ }
165
+
166
+ return Object.freeze({
167
+ attach,
168
+ dispatch,
169
+ dispose,
170
+ runtimeManager,
171
+ isAttached: () => attached,
172
+ isDisposed: () => disposed,
173
+ })
174
+ }
@@ -0,0 +1,64 @@
1
+ import { parseOpenCode2LoopCommandText } from "./commands.js"
2
+
3
+ function record(value) {
4
+ return value && typeof value === "object" ? value : undefined
5
+ }
6
+
7
+ function text(value) {
8
+ return typeof value === "string" && value.trim() ? value : undefined
9
+ }
10
+
11
+ function directoryFrom(raw) {
12
+ return text(record(raw?.location)?.directory)
13
+ }
14
+
15
+ function sessionIDFrom(raw) {
16
+ const data = record(raw?.data)
17
+ return text(data?.sessionID)
18
+ }
19
+
20
+ export function normalizeOpenCode2NativeEvent(raw) {
21
+ const type = text(raw?.type)
22
+ if (!type) return undefined
23
+
24
+ const data = record(raw?.data) || {}
25
+ const directory = directoryFrom(raw)
26
+ const sessionID = sessionIDFrom(raw)
27
+
28
+ if (type === "session.inbox.enqueued") {
29
+ const item = record(data.item)
30
+ const payload = record(item?.payload)
31
+ const parsed = item?.type === "user" ? parseOpenCode2LoopCommandText(payload?.text) : undefined
32
+ if (!sessionID || !parsed) return undefined
33
+ return Object.freeze({
34
+ kind: "command",
35
+ action: "executed",
36
+ sessionID,
37
+ directory,
38
+ name: parsed.name,
39
+ arguments: parsed.arguments,
40
+ })
41
+ }
42
+
43
+ if (type === "session.execution.started") {
44
+ if (!sessionID) return undefined
45
+ return Object.freeze({ kind: "session", action: "status", sessionID, directory, status: "busy" })
46
+ }
47
+
48
+ if (type === "session.execution.succeeded") {
49
+ if (!sessionID) return undefined
50
+ return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
51
+ }
52
+
53
+ if (type === "session.created") {
54
+ if (!sessionID) return undefined
55
+ return Object.freeze({ kind: "session", action: "created", sessionID, directory: directory || text(record(data.location)?.directory) })
56
+ }
57
+
58
+ if (type === "session.deleted") {
59
+ if (!sessionID) return undefined
60
+ return Object.freeze({ kind: "session", action: "deleted", sessionID, directory })
61
+ }
62
+
63
+ return undefined
64
+ }
@@ -0,0 +1,47 @@
1
+ import { inspectOpenCode2Context } from "./capabilities.js"
2
+ import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
3
+ import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
4
+ import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
5
+
6
+ export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"
7
+ export { OPENCODE_LOOP_V2_COMMANDS }
8
+
9
+ export const OpenCodeLoopV2ExperimentalPlugin = {
10
+ id: OPENCODE_LOOP_V2_PLUGIN_ID,
11
+ async setup(ctx) {
12
+ const capabilities = inspectOpenCode2Context(ctx)
13
+ if (!capabilities.commandTransform) {
14
+ throw new Error("OpenCode 2 command.transform capability is unavailable")
15
+ }
16
+
17
+ const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
18
+ if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
19
+ await commandRegistration?.dispose?.()
20
+ return undefined
21
+ }
22
+
23
+ let promptRuntime
24
+ const onRuntimeEvent = async (event) => promptRuntime?.onEvent(event)
25
+ const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
26
+ promptRuntime = createOpenCode2PromptRuntime({
27
+ prompt: (request) => runtime.prompt(request),
28
+ command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
29
+ })
30
+
31
+ try {
32
+ await runtime.start()
33
+ } catch (error) {
34
+ await promptRuntime?.dispose?.().catch(() => undefined)
35
+ await commandRegistration?.dispose?.().catch(() => undefined)
36
+ throw error
37
+ }
38
+
39
+ return async () => {
40
+ await promptRuntime?.dispose?.()
41
+ await runtime.dispose("plugin-cleanup")
42
+ await commandRegistration?.dispose?.()
43
+ }
44
+ },
45
+ }
46
+
47
+ export default OpenCodeLoopV2ExperimentalPlugin
@@ -0,0 +1,94 @@
1
+ import { createOpenCode2EventBridge } from "./event-bridge.js"
2
+
3
+ export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental"
4
+
5
+ function normalizePrompt(input) {
6
+ const sessionID = String(input?.sessionID || "").trim()
7
+ const text = String(input?.text || "")
8
+ if (!sessionID) throw new TypeError("prompt requires a session ID")
9
+ if (!text.trim()) throw new TypeError("prompt requires text")
10
+ return Object.freeze({
11
+ sessionID,
12
+ text,
13
+ agent: input?.agent,
14
+ model: input?.model,
15
+ noReply: input?.noReply === true,
16
+ })
17
+ }
18
+
19
+ function normalizeCommand(input) {
20
+ const sessionID = String(input?.sessionID || "").trim()
21
+ const command = String(input?.command || "").trim().replace(/^\/+/, "")
22
+ if (!sessionID) throw new TypeError("command requires a session ID")
23
+ if (!command) throw new TypeError("command requires a command name")
24
+ return Object.freeze({
25
+ sessionID,
26
+ command,
27
+ arguments: input?.arguments === undefined ? undefined : String(input.arguments),
28
+ agent: input?.agent,
29
+ model: input?.model,
30
+ delivery: input?.delivery,
31
+ resume: input?.resume,
32
+ })
33
+ }
34
+
35
+ export function createOpenCode2HostContract(options = {}) {
36
+ const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {}
37
+ let started = false
38
+ let disposed = false
39
+ let hostDisposed = false
40
+
41
+ const bridge = createOpenCode2EventBridge({
42
+ directory: options.directory,
43
+ onError: options.onError,
44
+ onEvent: async (event, runtime) => {
45
+ if (event.kind === "server" && event.action === "disposed") hostDisposed = true
46
+ await onEvent(event, runtime)
47
+ },
48
+ })
49
+
50
+ async function start() {
51
+ if (disposed) throw new Error("OpenCode 2 host contract is disposed")
52
+ if (started) return false
53
+ if (typeof options.subscribe !== "function") throw new TypeError("subscribe must be a function")
54
+ if (typeof options.sendPrompt !== "function") throw new TypeError("sendPrompt must be a function")
55
+ await bridge.attach(options.subscribe)
56
+ started = true
57
+ return true
58
+ }
59
+
60
+ async function prompt(input) {
61
+ if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable")
62
+ if (!started) throw new Error("OpenCode 2 host contract is not started")
63
+ const request = normalizePrompt(input)
64
+ const runtime = bridge.runtimeManager.observeExternal(request.sessionID)
65
+ return await options.sendPrompt(Object.freeze({ ...request, runtime }))
66
+ }
67
+
68
+ async function command(input) {
69
+ if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable")
70
+ if (!started) throw new Error("OpenCode 2 host contract is not started")
71
+ if (typeof options.sendCommand !== "function") throw new Error("OpenCode 2 session.command capability is unavailable")
72
+ const request = normalizeCommand(input)
73
+ const runtime = bridge.runtimeManager.observeExternal(request.sessionID)
74
+ return await options.sendCommand(Object.freeze({ ...request, runtime }))
75
+ }
76
+
77
+ async function dispose(reason = "host-contract-disposed") {
78
+ if (disposed) return false
79
+ disposed = true
80
+ await bridge.dispose(reason)
81
+ return true
82
+ }
83
+
84
+ return Object.freeze({
85
+ start,
86
+ prompt,
87
+ command,
88
+ dispose,
89
+ runtimeManager: bridge.runtimeManager,
90
+ isStarted: () => started,
91
+ isDisposed: () => disposed,
92
+ isHostDisposed: () => hostDisposed,
93
+ })
94
+ }