@bybrawe/opencode-loop 0.5.28 → 0.5.30

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 (59) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -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 +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/publish-workflow-test.mjs +25 -0
  21. package/scripts/scheduler-runtime-test.mjs +147 -0
  22. package/scripts/session-activity-test.mjs +95 -0
  23. package/scripts/session-status-test.mjs +220 -0
  24. package/scripts/v2-command-adapter-test.mjs +113 -0
  25. package/scripts/v2-command-runtime-test.mjs +98 -0
  26. package/scripts/v2-diagnostics-test.mjs +74 -0
  27. package/scripts/v2-host-contract-test.mjs +27 -0
  28. package/scripts/v2-plugin-sentinel-test.mjs +60 -8
  29. package/scripts/v2-prompt-interval-test.mjs +171 -0
  30. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  31. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  32. package/src/index.js +2751 -1594
  33. package/src/source/legacy-v1.js +168 -1860
  34. package/src/source/opencode/command-router.js +103 -0
  35. package/src/source/opencode/commands.js +83 -0
  36. package/src/source/opencode/goal-commands.js +107 -0
  37. package/src/source/opencode/loop-commands.js +195 -0
  38. package/src/source/opencode/loop-registration.js +100 -0
  39. package/src/source/opencode/messages.js +55 -0
  40. package/src/source/opencode2/capabilities.js +3 -1
  41. package/src/source/opencode2/commands.js +72 -0
  42. package/src/source/opencode2/diagnostics.js +80 -0
  43. package/src/source/opencode2/event-bridge.js +19 -1
  44. package/src/source/opencode2/events.js +64 -0
  45. package/src/source/opencode2/experimental.js +41 -1
  46. package/src/source/opencode2/host-contract.js +26 -0
  47. package/src/source/opencode2/prompt-runtime.js +381 -0
  48. package/src/source/opencode2/runtime-adapter.js +56 -0
  49. package/src/source/opencode2/status.js +40 -0
  50. package/src/source/runtime/compaction.js +122 -0
  51. package/src/source/runtime/executor.js +558 -0
  52. package/src/source/runtime/goal-policy.js +77 -0
  53. package/src/source/runtime/goal-runtime.js +231 -0
  54. package/src/source/runtime/goal-steering.js +198 -0
  55. package/src/source/runtime/job-workspace.js +148 -0
  56. package/src/source/runtime/scheduler.js +212 -0
  57. package/src/source/runtime/session-activity.js +112 -0
  58. package/src/source/runtime/session-status.js +197 -0
  59. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -18,7 +18,7 @@ export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
18
18
  "runtime.adapter",
19
19
  ])
20
20
 
21
- export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "file-definitions"
21
+ export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "plugin-transform"
22
22
 
23
23
  export function inspectOpenCode2Context(ctx) {
24
24
  const command = ctx?.command
@@ -31,6 +31,8 @@ export function inspectOpenCode2Context(ctx) {
31
31
  eventSubscribe: hasFunction(event, "subscribe"),
32
32
  sessionHook: hasFunction(session, "hook"),
33
33
  sessionPrompt: hasFunction(session, "prompt"),
34
+ sessionCommand: hasFunction(session, "command"),
35
+ sessionShell: hasFunction(session, "shell"),
34
36
  toolTransform: hasFunction(tool, "transform"),
35
37
  toolHook: hasFunction(tool, "hook"),
36
38
  })
@@ -0,0 +1,72 @@
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
+ "loop-export": Object.freeze({
35
+ description: "Export OpenCode Loop state for the current session.",
36
+ template: "OpenCode Loop export command handled locally. Reply exactly: OK.",
37
+ }),
38
+ "loop-help": Object.freeze({
39
+ description: "Show the experimental OpenCode 2 Loop command help.",
40
+ template: "OpenCode Loop help command handled locally. Reply exactly: OK.",
41
+ }),
42
+ "loop-doctor": Object.freeze({
43
+ description: "Show experimental OpenCode 2 Loop diagnostics.",
44
+ template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
45
+ }),
46
+ })
47
+
48
+ export function registerOpenCode2LoopCommands(draft) {
49
+ if (!draft || typeof draft.update !== "function") {
50
+ throw new Error("OpenCode 2 command draft.update capability is unavailable")
51
+ }
52
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
53
+ draft.update(name, (command) => {
54
+ command.template = definition.template
55
+ command.description = definition.description
56
+ })
57
+ }
58
+ }
59
+
60
+ export function parseOpenCode2LoopCommandText(value) {
61
+ if (typeof value !== "string") return undefined
62
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
63
+ const template = definition.template
64
+ if (value === template) return Object.freeze({ name, arguments: "" })
65
+ if (!value.startsWith(`${template}\n\n`)) continue
66
+ return Object.freeze({
67
+ name,
68
+ arguments: value.slice(template.length + 2).trim(),
69
+ })
70
+ }
71
+ return undefined
72
+ }
@@ -0,0 +1,80 @@
1
+ import { readState as defaultReadState, stateDir } from "../core/state.js"
2
+
3
+ export const OPENCODE_LOOP_V2_HELP_TEXT = [
4
+ "OpenCode Loop V2 experimental help:",
5
+ "/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
6
+ "/loop 5m --no-now --name later --multi <prompt> delayed named prompt loop",
7
+ "/loop 0s --command /review slash-command loop when the host exposes session.command",
8
+ "/loop-status show current Loop jobs",
9
+ "/loop-now [target] run matching jobs on the next idle boundary",
10
+ "/loop-pause [target] | /loop-resume [target] pause or resume jobs",
11
+ "/loop-stop [target] | /loop-remove [target] remove matching jobs",
12
+ "/loop-clear clear all jobs",
13
+ "/loop-export export current session Loop state as JSON",
14
+ "/loop-help show this experimental V2 help",
15
+ "/loop-doctor show local V2 diagnostics",
16
+ "Experimental V2 does not yet claim full stable-plugin parity; unsupported options fail closed.",
17
+ ].join("\n")
18
+
19
+ function scopeFrom(event) {
20
+ const directory = typeof event?.directory === "string" ? event.directory.trim() : ""
21
+ const sessionID = String(event?.sessionID || "").trim()
22
+ if (!directory || !sessionID) return undefined
23
+ return { directory, sessionID }
24
+ }
25
+
26
+ export function createOpenCode2DiagnosticsRuntime(options = {}) {
27
+ if (typeof options.prompt !== "function") throw new TypeError("V2 diagnostics runtime requires prompt()")
28
+ const readState = typeof options.readState === "function" ? options.readState : defaultReadState
29
+ const runtimeVersion = options.runtimeVersion || process.version
30
+ const runtimePlatform = options.runtimePlatform || process.platform
31
+
32
+ async function exportState(event) {
33
+ const scope = scopeFrom(event)
34
+ if (!scope) return { handled: false, reason: "missing-scope" }
35
+ const state = await readState(scope.directory, scope.sessionID)
36
+ const text = `OpenCode loop state export:\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\``
37
+ const request = { sessionID: scope.sessionID, text, noReply: true }
38
+ await options.prompt(request)
39
+ return { handled: true, accepted: true, state, request }
40
+ }
41
+
42
+ async function help(event) {
43
+ const scope = scopeFrom(event)
44
+ if (!scope) return { handled: false, reason: "missing-scope" }
45
+ const request = { sessionID: scope.sessionID, text: OPENCODE_LOOP_V2_HELP_TEXT, noReply: true }
46
+ await options.prompt(request)
47
+ return { handled: true, accepted: true, request }
48
+ }
49
+
50
+ async function doctor(event) {
51
+ const scope = scopeFrom(event)
52
+ if (!scope) return { handled: false, reason: "missing-scope" }
53
+ const state = await readState(scope.directory, scope.sessionID)
54
+ const text = [
55
+ "OpenCode Loop V2 doctor:",
56
+ "- plugin: bybrawe.opencode-loop.v2.experimental",
57
+ `- project directory: ${scope.directory}`,
58
+ `- state directory: ${stateDir(scope.directory)}`,
59
+ `- active jobs: ${(state.jobs || []).length}`,
60
+ `- node: ${runtimeVersion}`,
61
+ `- platform: ${runtimePlatform}`,
62
+ "- full stable parity: not claimed",
63
+ "- smoke test: /loop 0s --max-runs 1 continue the current task",
64
+ ].join("\n")
65
+ const request = { sessionID: scope.sessionID, text, noReply: true }
66
+ await options.prompt(request)
67
+ return { handled: true, accepted: true, state, request }
68
+ }
69
+
70
+ async function onEvent(event) {
71
+ if (event?.kind === "command" && event?.action === "executed") {
72
+ if (event?.name === "loop-export") return await exportState(event)
73
+ if (event?.name === "loop-help") return await help(event)
74
+ if (event?.name === "loop-doctor") return await doctor(event)
75
+ }
76
+ return { handled: false }
77
+ }
78
+
79
+ return Object.freeze({ onEvent, exportState, help, doctor })
80
+ }
@@ -1,5 +1,6 @@
1
1
  import { normalizeOpenCodeEvent } from "../runtime/events.js"
2
2
  import { createSessionRuntimeManager } from "../runtime/session-manager.js"
3
+ import { normalizeOpenCode2NativeEvent } from "./events.js"
3
4
 
4
5
  function cleanupRegistration(registration) {
5
6
  if (typeof registration === "function") return registration
@@ -41,6 +42,7 @@ export function createOpenCode2EventBridge({
41
42
  let disposed = false
42
43
  let managerDisposed = false
43
44
  let queue = Promise.resolve()
45
+ const sessionDirectories = new Map()
44
46
 
45
47
  function report(error) {
46
48
  try { onError(error) } catch {}
@@ -61,20 +63,35 @@ export function createOpenCode2EventBridge({
61
63
  return Boolean(current)
62
64
  }
63
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
+
64
79
  async function process(raw) {
65
80
  if (stopped) return undefined
66
- const event = normalizeOpenCodeEvent(raw)
81
+ const event = normalize(raw)
67
82
  if (!event || !sameDirectory(directory, event.directory)) return undefined
68
83
 
69
84
  const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
70
85
  await onEvent(event, runtime)
71
86
 
72
87
  if (event.kind === "session" && event.action === "deleted") {
88
+ sessionDirectories.delete(event.sessionID)
73
89
  runtimeManager.remove(event.sessionID, { expectedRuntime: runtime, reason: "session-deleted" })
74
90
  }
75
91
 
76
92
  if (event.kind === "server" && event.action === "disposed") {
77
93
  stopped = true
94
+ sessionDirectories.clear()
78
95
  disposeManager("server-disposed")
79
96
  }
80
97
 
@@ -141,6 +158,7 @@ export function createOpenCode2EventBridge({
141
158
  const cleanup = cleanupRegistration(registration)
142
159
  registration = undefined
143
160
  if (cleanup) await cleanup()
161
+ sessionDirectories.clear()
144
162
  disposeManager(reason)
145
163
  return true
146
164
  }
@@ -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
+ }
@@ -1,6 +1,11 @@
1
1
  import { inspectOpenCode2Context } from "./capabilities.js"
2
+ import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
3
+ import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
4
+ import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
5
+ import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
2
6
 
3
7
  export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"
8
+ export { OPENCODE_LOOP_V2_COMMANDS }
4
9
 
5
10
  export const OpenCodeLoopV2ExperimentalPlugin = {
6
11
  id: OPENCODE_LOOP_V2_PLUGIN_ID,
@@ -9,7 +14,42 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
9
14
  if (!capabilities.commandTransform) {
10
15
  throw new Error("OpenCode 2 command.transform capability is unavailable")
11
16
  }
12
- await ctx.command.transform(() => {})
17
+
18
+ const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
19
+ if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
20
+ await commandRegistration?.dispose?.()
21
+ return undefined
22
+ }
23
+
24
+ let promptRuntime
25
+ let diagnosticsRuntime
26
+ const onRuntimeEvent = async (event) => {
27
+ const promptResult = await promptRuntime?.onEvent(event)
28
+ if (promptResult?.handled) return promptResult
29
+ return await diagnosticsRuntime?.onEvent(event) ?? promptResult
30
+ }
31
+ const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
32
+ promptRuntime = createOpenCode2PromptRuntime({
33
+ prompt: (request) => runtime.prompt(request),
34
+ command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
35
+ })
36
+ diagnosticsRuntime = createOpenCode2DiagnosticsRuntime({
37
+ prompt: (request) => runtime.prompt(request),
38
+ })
39
+
40
+ try {
41
+ await runtime.start()
42
+ } catch (error) {
43
+ await promptRuntime?.dispose?.().catch(() => undefined)
44
+ await commandRegistration?.dispose?.().catch(() => undefined)
45
+ throw error
46
+ }
47
+
48
+ return async () => {
49
+ await promptRuntime?.dispose?.()
50
+ await runtime.dispose("plugin-cleanup")
51
+ await commandRegistration?.dispose?.()
52
+ }
13
53
  },
14
54
  }
15
55
 
@@ -16,6 +16,22 @@ function normalizePrompt(input) {
16
16
  })
17
17
  }
18
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
+
19
35
  export function createOpenCode2HostContract(options = {}) {
20
36
  const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {}
21
37
  let started = false
@@ -49,6 +65,15 @@ export function createOpenCode2HostContract(options = {}) {
49
65
  return await options.sendPrompt(Object.freeze({ ...request, runtime }))
50
66
  }
51
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
+
52
77
  async function dispose(reason = "host-contract-disposed") {
53
78
  if (disposed) return false
54
79
  disposed = true
@@ -59,6 +84,7 @@ export function createOpenCode2HostContract(options = {}) {
59
84
  return Object.freeze({
60
85
  start,
61
86
  prompt,
87
+ command,
62
88
  dispose,
63
89
  runtimeManager: bridge.runtimeManager,
64
90
  isStarted: () => started,