@bybrawe/opencode-loop 0.5.37 → 0.6.0

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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +105 -58
  13. package/scripts/install-test.mjs +6 -1
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +107 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. package/src/v1.js +1 -0
@@ -6,8 +6,12 @@ function promptRequest(request) {
6
6
  sessionID: request.sessionID,
7
7
  }
8
8
  if (request.noReply === true) {
9
+ // beta hosts understood noReply+parts; OpenCode 2.0.11 uses the ordinary
10
+ // prompt shape and resume=false for a durable non-running message.
9
11
  value.noReply = true
10
12
  value.parts = [{ type: "text", text: request.text }]
13
+ value.text = request.text
14
+ value.resume = false
11
15
  } else {
12
16
  value.text = request.text
13
17
  }
@@ -19,9 +23,14 @@ function promptRequest(request) {
19
23
  function commandRequest(request) {
20
24
  const value = {
21
25
  sessionID: request.sessionID,
26
+ // OpenCode 2.0.11 calls this field name; earlier beta clients called it command.
27
+ name: request.command,
22
28
  command: request.command,
23
29
  }
24
- if (request.arguments !== undefined) value.arguments = request.arguments
30
+ if (request.arguments !== undefined) {
31
+ value.arguments = request.arguments
32
+ value.text = request.arguments
33
+ }
25
34
  if (request.agent !== undefined) value.agent = request.agent
26
35
  if (request.model !== undefined) value.model = request.model
27
36
  if (request.delivery !== undefined) value.delivery = request.delivery
@@ -34,9 +43,13 @@ export function createOpenCode2RuntimeAdapter(ctx, options = {}) {
34
43
  if (!capabilities.eventSubscribe) throw new Error("OpenCode 2 event.subscribe capability is unavailable")
35
44
  if (!capabilities.sessionPrompt) throw new Error("OpenCode 2 session.prompt capability is unavailable")
36
45
 
46
+ const subscribe = options.eventSubscribeStyle === "stream"
47
+ ? () => ctx.event.subscribe()
48
+ : ctx.event.subscribe.bind(ctx.event)
49
+
37
50
  const host = createOpenCode2HostContract({
38
51
  directory: options.directory,
39
- subscribe: ctx.event.subscribe.bind(ctx.event),
52
+ subscribe,
40
53
  sendPrompt: (request) => ctx.session.prompt(promptRequest(request)),
41
54
  sendCommand: capabilities.sessionCommand ? (request) => ctx.session.command(commandRequest(request)) : undefined,
42
55
  onEvent: options.onEvent,
@@ -0,0 +1,16 @@
1
+ import path from 'node:path'
2
+
3
+ // The plugin location is not evidence about an arbitrary session.
4
+ // Resolve through the host before using the instance's local files.
5
+ export function createNativeSessionLocationGuard({ directory, workspaceID, getSession }) {
6
+ const expected = path.resolve(directory)
7
+ return async (sessionID) => {
8
+ if (typeof sessionID !== 'string' || !sessionID || typeof getSession !== 'function') return false
9
+ try {
10
+ const session = await getSession({ sessionID })
11
+ if (session?.id !== sessionID || typeof session.location?.directory !== 'string' || !path.isAbsolute(session.location.directory)) return false
12
+ if (path.relative(expected, path.resolve(session.location.directory)) !== '') return false
13
+ return (session.location.workspaceID ?? '') === (workspaceID ?? '')
14
+ } catch { return false }
15
+ }
16
+ }
@@ -23,6 +23,30 @@ function requireFunction(value, label) {
23
23
  return value
24
24
  }
25
25
 
26
+ function sessionErrorDetails(event, fallbackMessage) {
27
+ if (event?.type !== "session.error") return undefined
28
+ const sessionID = event?.properties?.sessionID
29
+ if (typeof sessionID !== "string" || !sessionID) return undefined
30
+ const error = event?.properties?.error
31
+ const name = error && typeof error === "object" && typeof error.name === "string"
32
+ ? error.name
33
+ : "SessionError"
34
+ const data = error && typeof error === "object" && error.data && typeof error.data === "object"
35
+ ? error.data
36
+ : undefined
37
+ const message = typeof data?.message === "string" && data.message.trim()
38
+ ? data.message.trim()
39
+ : fallbackMessage(error)
40
+ return {
41
+ sessionID,
42
+ error,
43
+ name,
44
+ message,
45
+ aborted: name === "MessageAbortedError",
46
+ retryable: name === "APIError" && data?.isRetryable === true,
47
+ }
48
+ }
49
+
26
50
  export function createLoopExecutor(options = {}) {
27
51
  const workspace = options.workspace || {}
28
52
  const goalPolicy = options.goalPolicy || {}
@@ -222,6 +246,64 @@ export function createLoopExecutor(options = {}) {
222
246
  return true
223
247
  }
224
248
 
249
+ async function handleSessionError(directory, client, event) {
250
+ const details = sessionErrorDetails(event, errorMessage)
251
+ if (!details || details.aborted) return false
252
+
253
+ const active = activeRuns.get(details.sessionID)
254
+ if (!active || active.compactionOnly) return false
255
+
256
+ const snapshot = active
257
+ clearActiveRun(details.sessionID)
258
+ clearSessionStatus(details.sessionID)
259
+
260
+ if (details.retryable) {
261
+ const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
262
+ reason: "session_error_retryable",
263
+ error: details.message,
264
+ })
265
+ await appendLoopLog(directory, "provider-session-error-retry", {
266
+ sessionID: details.sessionID,
267
+ job: refunded.job?.name || snapshot.jobId,
268
+ errorName: details.name,
269
+ error: details.message,
270
+ retryInMs: refunded.delayMs,
271
+ })
272
+ await toast(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning")
273
+ await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs)
274
+ return true
275
+ }
276
+
277
+ const state = await readState(directory, details.sessionID)
278
+ const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId)
279
+ if (job) {
280
+ job.failureCount = (job.failureCount || 0) + 1
281
+ job.paused = true
282
+ job.lastFailureReason = "session_error"
283
+ job.lastSessionErrorName = details.name
284
+ job.lastSessionError = details.message.slice(0, 4000)
285
+ job.lastSessionErrorAt = now()
286
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
287
+ await writeState(directory, details.sessionID, state)
288
+ await notifyJob(directory, job, "session_error")
289
+ }
290
+
291
+ await appendLoopLog(directory, "session-error", {
292
+ sessionID: details.sessionID,
293
+ job: job?.name || snapshot.jobId,
294
+ errorName: details.name,
295
+ error: details.message,
296
+ paused: Boolean(job?.paused),
297
+ })
298
+ await toast(
299
+ client,
300
+ `Loop paused after a terminal OpenCode session error: ${details.message}`,
301
+ "error",
302
+ )
303
+ await scheduleDueWork(directory, client, details.sessionID)
304
+ return true
305
+ }
306
+
225
307
  async function recoverStuckProviderRetry(directory, client, sessionID) {
226
308
  const active = activeRuns.get(sessionID)
227
309
  if (!active || active.compactionOnly) {
@@ -525,6 +607,7 @@ export function createLoopExecutor(options = {}) {
525
607
  clearActiveRun,
526
608
  disposeSession,
527
609
  recoverActiveDispatchFailure,
610
+ handleSessionError,
528
611
  recoverStuckProviderRetry,
529
612
  finalizeActiveRun,
530
613
  fireAction,
@@ -62,6 +62,7 @@ export function createGoalSteeringRuntime(options = {}) {
62
62
  ? Number(options.seenUserMessageMs)
63
63
  : DEFAULT_SEEN_USER_MESSAGE_MS
64
64
 
65
+ const compactingSessions = new Set()
65
66
  const pendingSteering = new Map()
66
67
  const seenUserMessages = new Map()
67
68
 
@@ -134,7 +135,7 @@ export function createGoalSteeringRuntime(options = {}) {
134
135
 
135
136
  const active = getActiveRun(sessionID)
136
137
  const activeGoalIDs = new Set(goals.map((goal) => goal.id))
137
- const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
138
+ const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
138
139
  let preempted = false
139
140
  let abortError = ""
140
141
 
@@ -154,7 +155,8 @@ export function createGoalSteeringRuntime(options = {}) {
154
155
  { path: { sessionID }, body: {} },
155
156
  { sessionID },
156
157
  )
157
- clearActiveRun(sessionID)
158
+ // A foreground turn can replace the active run while abort settles.
159
+ if (getActiveRun(sessionID) === active) clearActiveRun(sessionID)
158
160
  preempted = true
159
161
  } catch (error) {
160
162
  pendingSteering.delete(sessionID)
@@ -177,10 +179,21 @@ export function createGoalSteeringRuntime(options = {}) {
177
179
  observeAssistantMessage(event)
178
180
  const user = userMessageFromEvent(event)
179
181
  if (!user) return undefined
180
- return await handleUserMessage(directory, client, user)
182
+ // Persisted user-role events include core compaction markers, synthetic
183
+ // continuations and replayed history. Parts may not even be persisted yet.
184
+ // Only the pre-dispatch chat.message admission hook is steering authority.
185
+ if (alreadyHandled(user.sessionID, user.messageID)) return { handled: false, duplicate: true, ...user }
186
+ return { handled: false, reason: "untrusted-message-event", ...user }
187
+ }
188
+
189
+ function setCompacting(sessionID, value = true) {
190
+ if (typeof sessionID !== "string" || !sessionID) return
191
+ if (value) compactingSessions.add(sessionID)
192
+ else compactingSessions.delete(sessionID)
181
193
  }
182
194
 
183
195
  function clearSession(sessionID) {
196
+ compactingSessions.delete(sessionID)
184
197
  pendingSteering.delete(sessionID)
185
198
  const prefix = `${sessionID}\u0000`
186
199
  for (const key of seenUserMessages.keys()) if (key.startsWith(prefix)) seenUserMessages.delete(key)
@@ -193,6 +206,7 @@ export function createGoalSteeringRuntime(options = {}) {
193
206
  shouldSuppressIdle,
194
207
  hasPendingSteering: shouldSuppressIdle,
195
208
  pendingForSession,
209
+ setCompacting,
196
210
  clearSession,
197
211
  }
198
212
  }
@@ -58,10 +58,10 @@ export function createJobWorkspaceRuntime(options = {}) {
58
58
  if (!job.branch || job.branchDone) return job
59
59
  const branch = safeID(job.branch)
60
60
  const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
61
- if (inRepo.code !== 0) { job.branchDone = true; return job }
61
+ if (inRepo.code !== 0) { job.branchDone = false; job.branchUnavailable = true; return job }
62
62
  let result = await runProcess("git", ["switch", branch], directory, 30_000)
63
63
  if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
64
- job.branchDone = true
64
+ job.branchDone = result.code === 0
65
65
  await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
66
66
  await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
67
67
  return job
@@ -97,6 +97,13 @@ export function createJobWorkspaceRuntime(options = {}) {
97
97
 
98
98
  async function untilReached(directory, job) {
99
99
  if (!job.until) return false
100
+ // Goal contracts/archives/leases can contain the configured marker too.
101
+ // None of this control state proves completion of user-project work.
102
+ const controlRoots = new Set([
103
+ stateDir(directory),
104
+ ...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"]
105
+ .map((name) => path.join(directory, ".opencode", name)),
106
+ ].map((root) => path.resolve(root).toLowerCase()))
100
107
  const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
101
108
  for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
102
109
  let scanned = 0
@@ -108,6 +115,8 @@ export function createJobWorkspaceRuntime(options = {}) {
108
115
  if (scanned >= MAX_SCAN_FILES) return false
109
116
  if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
110
117
  const full = path.join(current, entry.name)
118
+ // Keep the explicit until.txt marker above, but never scan plugin state.
119
+ if (controlRoots.has(path.resolve(full).toLowerCase())) continue
111
120
  if (entry.isDirectory()) { if (await walk(full)) return true }
112
121
  else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
113
122
  }
@@ -103,6 +103,16 @@ export function createRunFinalizationRuntime(options = {}) {
103
103
 
104
104
  const terminal = await applyTerminalContinuationGuard(directory, client, sessionID, job)
105
105
  job = terminal.job
106
+ if (terminal.waitingUserPausedNow) {
107
+ await appendLoopLog(directory, "waiting-user", {
108
+ sessionID,
109
+ job: job.name || job.id,
110
+ count: job.waitingUserCount,
111
+ summary: String(terminal.text || "").slice(0, 1000),
112
+ })
113
+ await notifyJob(directory, job, "waiting_user")
114
+ await toast(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning")
115
+ }
106
116
  if (terminal.pausedNow) {
107
117
  await appendLoopLog(directory, "terminal-no-work", {
108
118
  sessionID,
@@ -113,7 +113,17 @@ export function createSessionStatusRuntime(options = {}) {
113
113
 
114
114
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
115
115
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
116
- if (!options.requireIdle && !options.forceStale) return true
116
+
117
+ // OpenCode 1.x can emit transient session.idle boundaries between tool
118
+ // steps while the same assistant turn is still in progress. When the
119
+ // message API can prove that the current assistant message is incomplete,
120
+ // keep the Loop-owned run active instead of treating that transient idle
121
+ // as a completed turn. Preserve the historical fallback when message
122
+ // completion evidence is unavailable.
123
+ if (!options.requireIdle && !options.forceStale) {
124
+ const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
125
+ return completion !== "incomplete"
126
+ }
117
127
 
118
128
  const completion = options.forceStale
119
129
  ? await activeRunCompletionFromMessages(directory, client, sessionID, active)
@@ -194,7 +204,11 @@ export function createSessionStatusRuntime(options = {}) {
194
204
  : completion === "completed"
195
205
  ? "status-message-complete-recovery"
196
206
  : "status-stale-recovery"
197
- await appendLoopLog(directory, recoveryEvent, logDetails)
207
+ active.statusRecoveryLogEvents ??= new Set()
208
+ if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
209
+ active.statusRecoveryLogEvents.add(recoveryEvent)
210
+ await appendLoopLog(directory, recoveryEvent, logDetails)
211
+ }
198
212
  return "idle"
199
213
  }
200
214
  }
@@ -1,4 +1,4 @@
1
- import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../core/continuation.js"
1
+ import { isCompletionBoundedContinuation, isContinuationShorthand, isTerminalNoWorkReply, isWaitingUserReply } from "../core/continuation.js"
2
2
  import { orderedSessionMessages, readRecentSessionMessages } from "../opencode/host.js"
3
3
 
4
4
  function messageText(message) {
@@ -17,10 +17,13 @@ function messageText(message) {
17
17
  }
18
18
 
19
19
  export async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
20
- // `/loop devam et` is intentionally infinite. The guard is only for an
21
- // explicit completion-bounded request such as "bitene kadar" / "until done".
22
- if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
23
- return { job, terminal: false, pausedNow: false }
20
+ // Plain continuation remains intentionally infinite for ordinary work and
21
+ // even ordinary "done" replies. We only inspect it for an explicit
22
+ // waiting-user dependency so a blocked autonomous loop does not poll forever.
23
+ const completionBounded = isCompletionBoundedContinuation(job?.action)
24
+ const continuation = completionBounded || isContinuationShorthand(job?.action)
25
+ if (job?.scheduleMode !== "idle" || !continuation) {
26
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false }
24
27
  }
25
28
 
26
29
  const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8)
@@ -37,10 +40,32 @@ export async function applyTerminalContinuationGuard(directory, client, sessionI
37
40
  }
38
41
 
39
42
  const text = messageText(tail)
43
+ const waitingUser = isWaitingUserReply(text)
44
+ if (waitingUser) {
45
+ if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
46
+ job.waitingUserCount = (job.waitingUserCount || 0) + 1
47
+ job.lastWaitingUserAt = Date.now()
48
+ job.lastWaitingUserSummary = text.slice(0, 1000)
49
+ const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2)
50
+ const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused
51
+ if (waitingUserPausedNow) {
52
+ job.paused = true
53
+ job.lastFailureReason = "waiting_user"
54
+ }
55
+ return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text }
56
+ }
57
+
58
+ if (job.waitingUserCount) job.waitingUserCount = 0
59
+
60
+ // A normal completion/no-work reply still cannot stop plain /loop devam et.
61
+ if (!completionBounded) {
62
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text }
63
+ }
64
+
40
65
  const terminal = isTerminalNoWorkReply(text)
41
66
  if (!terminal) {
42
67
  if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
43
- return { job, terminal: false, pausedNow: false, text }
68
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text }
44
69
  }
45
70
 
46
71
  job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1
@@ -52,5 +77,5 @@ export async function applyTerminalContinuationGuard(directory, client, sessionI
52
77
  job.paused = true
53
78
  job.lastFailureReason = "terminal_no_work"
54
79
  }
55
- return { job, terminal: true, pausedNow, text }
80
+ return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text }
56
81
  }
@@ -0,0 +1,26 @@
1
+ import OpenCodeLoopV2ExperimentalPlugin from "./opencode2/experimental.js"
2
+
3
+ // Load the legacy runtime only when a V1 host invokes its server contract.
4
+ // A runtime URL prevents bundlers from inlining the legacy implementation.
5
+ // Both source and package entries have a sibling v1.js; the package shim
6
+ // delegates to the standalone V1 bundle, without initializing it on V2.
7
+ async function OpenCodeLoopPlugin(...args) {
8
+ const { default: legacy } = await import(new URL("./v1.js", import.meta.url).href)
9
+ return legacy(...args)
10
+ }
11
+
12
+ export const OPENCODE_LOOP_PLUGIN_ID = "@bybrawe/opencode-loop"
13
+
14
+ export const OpenCodeLoopPluginModule = Object.freeze({
15
+ id: OPENCODE_LOOP_PLUGIN_ID,
16
+
17
+ // OpenCode 1.x PluginModule contract.
18
+ server: OpenCodeLoopPlugin,
19
+
20
+ // OpenCode 2.x promise-plugin contract.
21
+ setup: (context) => OpenCodeLoopV2ExperimentalPlugin.setup(context),
22
+ })
23
+
24
+ export { OpenCodeLoopPlugin, OpenCodeLoopV2ExperimentalPlugin }
25
+
26
+ export default OpenCodeLoopPluginModule
package/src/v1.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./index.js"