@bybrawe/opencode-loop 0.5.26 → 0.5.28

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 (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +9 -3
  3. package/package.json +8 -4
  4. package/scripts/comprehensive-test.mjs +49 -4
  5. package/scripts/comprehensive-watchdog.mjs +32 -0
  6. package/scripts/host-adapter-contract-test.mjs +147 -0
  7. package/scripts/host-bridge-parity-test.mjs +22 -0
  8. package/scripts/host-loop-canary.mjs +357 -0
  9. package/scripts/loopd-test.mjs +45 -18
  10. package/scripts/loopd.mjs +92 -22
  11. package/scripts/opencode2-host-canary.mjs +194 -0
  12. package/scripts/runtime-contract-test.mjs +59 -0
  13. package/scripts/runtime-event-test.mjs +78 -0
  14. package/scripts/runtime-scope-test.mjs +46 -0
  15. package/scripts/session-manager-test.mjs +48 -0
  16. package/scripts/v2-event-bridge-test.mjs +72 -0
  17. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  18. package/scripts/v2-host-contract-test.mjs +55 -0
  19. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  20. package/src/index.js +2304 -1864
  21. package/src/opencode/v1.js +1 -0
  22. package/src/source/core/args.js +281 -0
  23. package/src/source/core/jobs.js +80 -0
  24. package/src/source/core/process.js +51 -0
  25. package/src/source/core/state.js +211 -0
  26. package/src/source/legacy-v1.js +1944 -0
  27. package/src/source/opencode/adapter.js +21 -0
  28. package/src/source/opencode/host.js +134 -0
  29. package/src/source/opencode/sdk.js +38 -0
  30. package/src/source/opencode/session-context.js +50 -0
  31. package/src/source/opencode2/capabilities.js +69 -0
  32. package/src/source/opencode2/event-bridge.js +156 -0
  33. package/src/source/opencode2/experimental.js +16 -0
  34. package/src/source/opencode2/host-contract.js +68 -0
  35. package/src/source/runtime/events.js +113 -0
  36. package/src/source/runtime/observer.js +23 -0
  37. package/src/source/runtime/scope.js +56 -0
  38. package/src/source/runtime/session-manager.js +100 -0
  39. package/src/source/runtime/session-registry.js +58 -0
  40. package/src/source/runtime/timers.js +45 -0
  41. package/src/source/v1.js +69 -0
@@ -0,0 +1,21 @@
1
+ import {
2
+ activeRunCompletionFromMessages,
3
+ compactSession,
4
+ executeTuiCommand,
5
+ log,
6
+ readRecentSessionMessages,
7
+ resolveCompactionModel,
8
+ toast,
9
+ } from "./host.js"
10
+
11
+ export function createOpenCodeHostAdapter(client, directory) {
12
+ return Object.freeze({
13
+ executeTuiCommand: (command) => executeTuiCommand(client, command),
14
+ readRecentMessages: (sessionID, limit = 12) => readRecentSessionMessages(client, sessionID, directory, limit),
15
+ activeRunCompletion: (sessionID, active) => activeRunCompletionFromMessages(directory, client, sessionID, active),
16
+ resolveCompactionModel: (sessionID, preferredModel) => resolveCompactionModel(directory, client, sessionID, preferredModel),
17
+ compactSession: (sessionID, preferredModel) => compactSession(directory, client, sessionID, preferredModel),
18
+ log: (level, message, extra) => log(client, level, message, extra),
19
+ toast: (message, variant) => toast(client, message, variant),
20
+ })
21
+ }
@@ -0,0 +1,134 @@
1
+ import { sdkErrorMessage, sdkCall } from "./sdk.js"
2
+ import { normalizedModelRef, captureSessionExecutionContext, getSessionExecutionContext, setSessionExecutionContext } from "./session-context.js"
3
+
4
+ const SERVICE = "opencode-loop"
5
+
6
+ export function fireSdk(client, label, method, ...argsList) {
7
+ const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
8
+ void pending.catch((error) => {
9
+ log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
10
+ })
11
+ return pending
12
+ }
13
+
14
+ export async function executeTuiCommand(client, command) {
15
+ if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
16
+ return await sdkCall(
17
+ client.tui.executeCommand.bind(client.tui),
18
+ { body: { command } },
19
+ { command },
20
+ )
21
+ }
22
+
23
+ export function compactTuiCommandName(command = "compact") {
24
+ const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
25
+ if (normalized === "compact" || normalized === "summarize") return "session_compact"
26
+ return undefined
27
+ }
28
+
29
+ export async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
30
+ if (!client?.session?.messages) return undefined
31
+ const query = { limit }
32
+ if (directory) query.directory = directory
33
+ try {
34
+ const messages = await sdkCall(
35
+ client.session.messages.bind(client.session),
36
+ { path: { id: sessionID }, query },
37
+ { path: { sessionID }, query },
38
+ { sessionID, ...query },
39
+ )
40
+ return Array.isArray(messages) ? messages : undefined
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ export function orderedSessionMessages(messages) {
47
+ return (messages || [])
48
+ .map((message, index) => {
49
+ const info = message?.info || message || {}
50
+ const created = Number(info?.time?.created || 0)
51
+ return { message, index, created: Number.isFinite(created) ? created : 0 }
52
+ })
53
+ .sort((a, b) => a.created - b.created || a.index - b.index)
54
+ .map((entry) => entry.message)
55
+ }
56
+
57
+ export async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
58
+ const messages = await readRecentSessionMessages(client, sessionID, directory)
59
+ if (!messages) return "unknown"
60
+ const tail = orderedSessionMessages(messages).at(-1)
61
+ const info = tail?.info || tail
62
+ if (!info || info.role !== "assistant") return "incomplete"
63
+ const completed = Number(info?.time?.completed || 0)
64
+ const created = Number(info?.time?.created || 0)
65
+ if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
66
+ const startedAt = Number(active?.startedAt || 0)
67
+ if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
68
+ return "completed"
69
+ }
70
+
71
+ export async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
72
+ const preferred = normalizedModelRef(preferredModel)
73
+ if (preferred) return preferred
74
+ const cached = normalizedModelRef(getSessionExecutionContext(sessionID)?.model)
75
+ if (cached) return cached
76
+ const captured = await captureSessionExecutionContext(client, sessionID)
77
+ const capturedModel = normalizedModelRef(captured?.model)
78
+ if (capturedModel) return capturedModel
79
+ const messages = await readRecentSessionMessages(client, sessionID, directory)
80
+ for (const message of orderedSessionMessages(messages).reverse()) {
81
+ const info = message?.info || message
82
+ const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
83
+ if (!model) continue
84
+ const previous = getSessionExecutionContext(sessionID) || {}
85
+ setSessionExecutionContext(sessionID, { ...previous, model })
86
+ return model
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ export async function compactSession(directory, client, sessionID, preferredModel) {
92
+ // Prefer the native TUI command when a TUI is present. Headless/server hosts
93
+ // fall back to session.summarize, whose current API requires an explicit
94
+ // provider/model pair.
95
+ for (const command of ["session.compact", "session_compact"]) {
96
+ try {
97
+ await executeTuiCommand(client, command)
98
+ return true
99
+ } catch (error) {
100
+ await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
101
+ }
102
+ }
103
+ try {
104
+ if (!client?.session?.summarize) throw new Error("client.session.summarize is not available")
105
+ const model = await resolveCompactionModel(directory, client, sessionID, preferredModel)
106
+ if (!model) throw new Error("could not resolve a provider/model for session.summarize")
107
+ const body = { providerID: model.providerID, modelID: model.modelID, auto: false }
108
+ await sdkCall(
109
+ client.session.summarize.bind(client.session),
110
+ { path: { id: sessionID }, body },
111
+ { path: { sessionID }, body },
112
+ { sessionID, ...body },
113
+ )
114
+ return true
115
+ } catch (error) {
116
+ await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
117
+ }
118
+ await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
119
+ return false
120
+ }
121
+
122
+ export async function log(client, level, message, extra) {
123
+ try {
124
+ await sdkCall(
125
+ client.app.log.bind(client.app),
126
+ { body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
127
+ extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
128
+ )
129
+ } catch {}
130
+ }
131
+
132
+ export async function toast(client, message, variant = "info") {
133
+ try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
134
+ }
@@ -0,0 +1,38 @@
1
+ // OpenCode SDK compatibility helpers.
2
+ export function sdkError(result) {
3
+ if (!result || typeof result !== "object") return undefined
4
+ return result.error || result.error === null ? result.error : undefined
5
+ }
6
+
7
+ export function sdkData(result) {
8
+ if (!result || typeof result !== "object") return result
9
+ return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result
10
+ }
11
+
12
+ export function sdkErrorMessage(error) {
13
+ if (!error) return "unknown SDK error"
14
+ if (error instanceof Error) return error.message
15
+ if (typeof error === "string") return error
16
+ if (typeof error === "object") {
17
+ if (typeof error.message === "string") return error.message
18
+ if (typeof error.name === "string") return error.name
19
+ try { return JSON.stringify(error).slice(0, 400) } catch {}
20
+ }
21
+ return String(error)
22
+ }
23
+
24
+ export async function sdkCall(method, ...argsList) {
25
+ let firstError
26
+ for (const args of argsList) {
27
+ if (args === undefined) continue
28
+ try {
29
+ const result = await method(args)
30
+ const error = sdkError(result)
31
+ if (!error) return sdkData(result)
32
+ firstError = firstError || new Error(sdkErrorMessage(error))
33
+ } catch (error) {
34
+ firstError = firstError || error
35
+ }
36
+ }
37
+ throw firstError || new Error("SDK call failed without arguments")
38
+ }
@@ -0,0 +1,50 @@
1
+ import { sdkCall } from "./sdk.js"
2
+
3
+ const LOCAL_COMMAND_AGENT = "opencode-loop-local"
4
+ const sessionExecutionContexts = new Map()
5
+
6
+ export function normalizedModelRef(model) {
7
+ if (typeof model === "string") {
8
+ const separator = model.indexOf("/")
9
+ if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
10
+ return undefined
11
+ }
12
+ const providerID = model?.providerID
13
+ const modelID = model?.modelID || model?.id
14
+ if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
15
+ return { providerID, modelID }
16
+ }
17
+
18
+ export function updateSessionExecutionContext(info) {
19
+ const sessionID = info?.sessionID || info?.id
20
+ if (typeof sessionID !== "string") return
21
+ const previous = sessionExecutionContexts.get(sessionID) || {}
22
+ const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
23
+ const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
24
+ ? candidateAgent
25
+ : previous.agent
26
+ const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
27
+ sessionExecutionContexts.set(sessionID, { agent, model })
28
+ }
29
+
30
+ export async function captureSessionExecutionContext(client, sessionID) {
31
+ if (client?.session?.get) {
32
+ try {
33
+ const info = await sdkCall(
34
+ client.session.get.bind(client.session),
35
+ { path: { id: sessionID } },
36
+ { path: { sessionID } },
37
+ { sessionID },
38
+ )
39
+ updateSessionExecutionContext(info)
40
+ } catch {}
41
+ }
42
+ const context = sessionExecutionContexts.get(sessionID) || {}
43
+ const normalized = { agent: context.agent || "build", model: context.model }
44
+ sessionExecutionContexts.set(sessionID, normalized)
45
+ return normalized
46
+ }
47
+
48
+ export function getSessionExecutionContext(sessionID) { return sessionExecutionContexts.get(sessionID) }
49
+ export function setSessionExecutionContext(sessionID, context) { sessionExecutionContexts.set(sessionID, context); return context }
50
+ export function deleteSessionExecutionContext(sessionID) { sessionExecutionContexts.delete(sessionID) }
@@ -0,0 +1,69 @@
1
+ function hasFunction(value, key) {
2
+ return Boolean(value && typeof value[key] === "function")
3
+ }
4
+
5
+ function frozenRecord(value) {
6
+ return Object.freeze(value)
7
+ }
8
+
9
+ export const OPENCODE_LOOP_V2_HOST_REQUIREMENTS = Object.freeze([
10
+ "event.subscribe",
11
+ "session.prompt",
12
+ ])
13
+
14
+ export const OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED = false
15
+
16
+ export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
17
+ ...OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
18
+ "runtime.adapter",
19
+ ])
20
+
21
+ export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "file-definitions"
22
+
23
+ export function inspectOpenCode2Context(ctx) {
24
+ const command = ctx?.command
25
+ const session = ctx?.session
26
+ const event = ctx?.event
27
+ const tool = ctx?.tool
28
+
29
+ return frozenRecord({
30
+ commandTransform: hasFunction(command, "transform"),
31
+ eventSubscribe: hasFunction(event, "subscribe"),
32
+ sessionHook: hasFunction(session, "hook"),
33
+ sessionPrompt: hasFunction(session, "prompt"),
34
+ toolTransform: hasFunction(tool, "transform"),
35
+ toolHook: hasFunction(tool, "hook"),
36
+ })
37
+ }
38
+
39
+ export function inspectOpenCode2CommandDraft(draft) {
40
+ return frozenRecord({
41
+ list: hasFunction(draft, "list"),
42
+ get: hasFunction(draft, "get"),
43
+ update: hasFunction(draft, "update"),
44
+ remove: hasFunction(draft, "remove"),
45
+ })
46
+ }
47
+
48
+ export function openCode2LoopRuntimeStatus(ctx, commandDraft) {
49
+ const context = inspectOpenCode2Context(ctx)
50
+ const command = inspectOpenCode2CommandDraft(commandDraft)
51
+ const hostBlockers = []
52
+
53
+ if (!context.eventSubscribe) hostBlockers.push("event.subscribe")
54
+ if (!context.sessionPrompt) hostBlockers.push("session.prompt")
55
+
56
+ const blockers = [...hostBlockers]
57
+ if (!OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED) blockers.push("runtime.adapter")
58
+
59
+ return frozenRecord({
60
+ ready: blockers.length === 0,
61
+ hostReady: hostBlockers.length === 0,
62
+ implementationReady: OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED,
63
+ blockers: Object.freeze(blockers),
64
+ hostBlockers: Object.freeze(hostBlockers),
65
+ commandSource: OPENCODE_LOOP_V2_COMMAND_SOURCE,
66
+ context,
67
+ command,
68
+ })
69
+ }
@@ -0,0 +1,156 @@
1
+ import { normalizeOpenCodeEvent } from "../runtime/events.js"
2
+ import { createSessionRuntimeManager } from "../runtime/session-manager.js"
3
+
4
+ function cleanupRegistration(registration) {
5
+ if (typeof registration === "function") return registration
6
+ if (!registration || typeof registration !== "object") return undefined
7
+ for (const key of ["dispose", "unsubscribe", "close"]) {
8
+ if (typeof registration[key] === "function") return registration[key].bind(registration)
9
+ }
10
+ return undefined
11
+ }
12
+
13
+ function eventStream(registration) {
14
+ const stream = registration?.stream ?? registration
15
+ if (!stream || typeof stream[Symbol.asyncIterator] !== "function") {
16
+ throw new TypeError("OpenCode 2 event subscribe must return an async event stream")
17
+ }
18
+ return stream
19
+ }
20
+
21
+ function sameDirectory(expected, actual) {
22
+ if (!expected || !actual) return true
23
+ return String(expected) === String(actual)
24
+ }
25
+
26
+ export function createOpenCode2EventBridge({
27
+ directory,
28
+ onEvent = async () => {},
29
+ onError = () => {},
30
+ runtimeManager = createSessionRuntimeManager(),
31
+ } = {}) {
32
+ if (typeof onEvent !== "function") throw new TypeError("OpenCode 2 event bridge requires onEvent to be a function")
33
+ if (typeof onError !== "function") throw new TypeError("OpenCode 2 event bridge requires onError to be a function")
34
+
35
+ let registration
36
+ let iterator
37
+ let iteratorClosed = false
38
+ let pump
39
+ let attached = false
40
+ let stopped = false
41
+ let disposed = false
42
+ let managerDisposed = false
43
+ let queue = Promise.resolve()
44
+
45
+ function report(error) {
46
+ try { onError(error) } catch {}
47
+ }
48
+
49
+ function disposeManager(reason) {
50
+ if (managerDisposed) return false
51
+ managerDisposed = true
52
+ return runtimeManager.dispose(reason)
53
+ }
54
+
55
+ async function closeIterator() {
56
+ if (iteratorClosed) return false
57
+ iteratorClosed = true
58
+ const current = iterator
59
+ iterator = undefined
60
+ if (typeof current?.return === "function") await current.return()
61
+ return Boolean(current)
62
+ }
63
+
64
+ async function process(raw) {
65
+ if (stopped) return undefined
66
+ const event = normalizeOpenCodeEvent(raw)
67
+ if (!event || !sameDirectory(directory, event.directory)) return undefined
68
+
69
+ const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
70
+ await onEvent(event, runtime)
71
+
72
+ if (event.kind === "session" && event.action === "deleted") {
73
+ runtimeManager.remove(event.sessionID, { expectedRuntime: runtime, reason: "session-deleted" })
74
+ }
75
+
76
+ if (event.kind === "server" && event.action === "disposed") {
77
+ stopped = true
78
+ disposeManager("server-disposed")
79
+ }
80
+
81
+ return event
82
+ }
83
+
84
+ function dispatch(raw) {
85
+ if (stopped) return Promise.resolve(undefined)
86
+ const result = queue.then(() => process(raw))
87
+ queue = result.catch(() => undefined)
88
+ return result
89
+ }
90
+
91
+ function callback(raw) {
92
+ const pending = dispatch(raw)
93
+ pending.catch(report)
94
+ return pending.catch(() => undefined)
95
+ }
96
+
97
+ async function consume(stream) {
98
+ const current = stream[Symbol.asyncIterator]()
99
+ iterator = current
100
+ iteratorClosed = false
101
+ try {
102
+ while (!stopped) {
103
+ const next = await current.next()
104
+ if (next?.done) break
105
+ await dispatch(next?.value)
106
+ }
107
+ } catch (error) {
108
+ if (!stopped) report(error)
109
+ } finally {
110
+ if (stopped && !iteratorClosed) await closeIterator().catch(() => undefined)
111
+ if (iterator === current) iterator = undefined
112
+ }
113
+ }
114
+
115
+ async function attach(subscribe) {
116
+ if (disposed) throw new Error("OpenCode 2 event bridge is disposed")
117
+ if (attached) throw new Error("OpenCode 2 event bridge is already attached")
118
+ if (typeof subscribe !== "function") throw new TypeError("OpenCode 2 event bridge requires an event subscribe function")
119
+
120
+ if (subscribe.length > 0) {
121
+ registration = await subscribe(callback)
122
+ attached = true
123
+ return registration
124
+ }
125
+
126
+ registration = await subscribe()
127
+ const stream = eventStream(registration)
128
+ attached = true
129
+ pump = consume(stream)
130
+ return registration
131
+ }
132
+
133
+ async function dispose(reason = "bridge-disposed") {
134
+ if (disposed) return false
135
+ disposed = true
136
+ stopped = true
137
+ await closeIterator().catch(() => undefined)
138
+ await pump?.catch(() => undefined)
139
+ await queue.catch(() => undefined)
140
+
141
+ const cleanup = cleanupRegistration(registration)
142
+ registration = undefined
143
+ if (cleanup) await cleanup()
144
+ disposeManager(reason)
145
+ return true
146
+ }
147
+
148
+ return Object.freeze({
149
+ attach,
150
+ dispatch,
151
+ dispose,
152
+ runtimeManager,
153
+ isAttached: () => attached,
154
+ isDisposed: () => disposed,
155
+ })
156
+ }
@@ -0,0 +1,16 @@
1
+ import { inspectOpenCode2Context } from "./capabilities.js"
2
+
3
+ export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"
4
+
5
+ export const OpenCodeLoopV2ExperimentalPlugin = {
6
+ id: OPENCODE_LOOP_V2_PLUGIN_ID,
7
+ async setup(ctx) {
8
+ const capabilities = inspectOpenCode2Context(ctx)
9
+ if (!capabilities.commandTransform) {
10
+ throw new Error("OpenCode 2 command.transform capability is unavailable")
11
+ }
12
+ await ctx.command.transform(() => {})
13
+ },
14
+ }
15
+
16
+ export default OpenCodeLoopV2ExperimentalPlugin
@@ -0,0 +1,68 @@
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
+ export function createOpenCode2HostContract(options = {}) {
20
+ const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {}
21
+ let started = false
22
+ let disposed = false
23
+ let hostDisposed = false
24
+
25
+ const bridge = createOpenCode2EventBridge({
26
+ directory: options.directory,
27
+ onError: options.onError,
28
+ onEvent: async (event, runtime) => {
29
+ if (event.kind === "server" && event.action === "disposed") hostDisposed = true
30
+ await onEvent(event, runtime)
31
+ },
32
+ })
33
+
34
+ async function start() {
35
+ if (disposed) throw new Error("OpenCode 2 host contract is disposed")
36
+ if (started) return false
37
+ if (typeof options.subscribe !== "function") throw new TypeError("subscribe must be a function")
38
+ if (typeof options.sendPrompt !== "function") throw new TypeError("sendPrompt must be a function")
39
+ await bridge.attach(options.subscribe)
40
+ started = true
41
+ return true
42
+ }
43
+
44
+ async function prompt(input) {
45
+ if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable")
46
+ if (!started) throw new Error("OpenCode 2 host contract is not started")
47
+ const request = normalizePrompt(input)
48
+ const runtime = bridge.runtimeManager.observeExternal(request.sessionID)
49
+ return await options.sendPrompt(Object.freeze({ ...request, runtime }))
50
+ }
51
+
52
+ async function dispose(reason = "host-contract-disposed") {
53
+ if (disposed) return false
54
+ disposed = true
55
+ await bridge.dispose(reason)
56
+ return true
57
+ }
58
+
59
+ return Object.freeze({
60
+ start,
61
+ prompt,
62
+ dispose,
63
+ runtimeManager: bridge.runtimeManager,
64
+ isStarted: () => started,
65
+ isDisposed: () => disposed,
66
+ isHostDisposed: () => hostDisposed,
67
+ })
68
+ }
@@ -0,0 +1,113 @@
1
+ function record(value) {
2
+ return value && typeof value === "object" ? value : undefined
3
+ }
4
+
5
+ function text(value) {
6
+ return typeof value === "string" && value.trim() ? value : undefined
7
+ }
8
+
9
+ function envelope(input) {
10
+ const outer = record(input)
11
+ if (!outer) return undefined
12
+ const payload = record(outer.payload)
13
+ if (payload && text(payload.type)) {
14
+ return { directory: text(outer.directory), event: payload }
15
+ }
16
+ const wrappedEvent = record(outer.event)
17
+ if (wrappedEvent && text(wrappedEvent.type)) {
18
+ return { directory: text(outer.directory), event: wrappedEvent }
19
+ }
20
+ if (!text(outer.type)) return undefined
21
+ return { directory: undefined, event: outer }
22
+ }
23
+
24
+ function freezeEvent(value) {
25
+ return Object.freeze(value)
26
+ }
27
+
28
+ export function normalizeOpenCodeEvent(input) {
29
+ const parsed = envelope(input)
30
+ if (!parsed) return undefined
31
+
32
+ const { directory, event } = parsed
33
+ const properties = record(event.properties) || {}
34
+
35
+ if (event.type === "session.status") {
36
+ const sessionID = text(properties.sessionID)
37
+ const status = text(record(properties.status)?.type)
38
+ if (!sessionID || !status) return undefined
39
+ return freezeEvent({ kind: "session", action: "status", sessionID, directory, status })
40
+ }
41
+
42
+ if (event.type === "session.idle" || event.type === "session.compacted") {
43
+ const sessionID = text(properties.sessionID)
44
+ if (!sessionID) return undefined
45
+ return freezeEvent({
46
+ kind: "session",
47
+ action: event.type === "session.idle" ? "idle" : "compacted",
48
+ sessionID,
49
+ directory,
50
+ })
51
+ }
52
+
53
+ if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
54
+ const info = record(properties.info)
55
+ const sessionID = text(info?.id)
56
+ if (!sessionID) return undefined
57
+ return freezeEvent({
58
+ kind: "session",
59
+ action: event.type.slice("session.".length),
60
+ sessionID,
61
+ directory: directory || text(info?.directory),
62
+ parentID: text(info?.parentID),
63
+ })
64
+ }
65
+
66
+ if (event.type === "session.error") {
67
+ const sessionID = text(properties.sessionID)
68
+ if (!sessionID) return undefined
69
+ return freezeEvent({ kind: "session", action: "error", sessionID, directory })
70
+ }
71
+
72
+ if (event.type === "message.updated") {
73
+ const info = record(properties.info)
74
+ const sessionID = text(info?.sessionID)
75
+ const messageID = text(info?.id)
76
+ const role = text(info?.role)
77
+ if (!sessionID || !messageID || !role) return undefined
78
+ const time = record(info?.time)
79
+ return freezeEvent({
80
+ kind: "message",
81
+ action: "updated",
82
+ sessionID,
83
+ directory,
84
+ messageID,
85
+ role,
86
+ completedAt: Number.isFinite(time?.completed) ? time.completed : undefined,
87
+ finish: text(info?.finish),
88
+ })
89
+ }
90
+
91
+ if (event.type === "command.executed") {
92
+ const sessionID = text(properties.sessionID)
93
+ const name = text(properties.name)
94
+ if (!sessionID || !name) return undefined
95
+ return freezeEvent({
96
+ kind: "command",
97
+ action: "executed",
98
+ sessionID,
99
+ directory,
100
+ name,
101
+ arguments: typeof properties.arguments === "string" ? properties.arguments : "",
102
+ messageID: text(properties.messageID),
103
+ })
104
+ }
105
+
106
+ if (event.type === "server.instance.disposed") {
107
+ const disposedDirectory = directory || text(properties.directory)
108
+ if (!disposedDirectory) return undefined
109
+ return freezeEvent({ kind: "server", action: "disposed", directory: disposedDirectory })
110
+ }
111
+
112
+ return undefined
113
+ }
@@ -0,0 +1,23 @@
1
+ import { normalizeOpenCodeEvent } from "./events.js"
2
+
3
+ export function observeRuntimeEvent(manager, input) {
4
+ const event = normalizeOpenCodeEvent(input)
5
+ if (!event || !manager) return event
6
+
7
+ try {
8
+ if (event.kind === "server" && event.action === "disposed") {
9
+ manager.dispose?.("server-disposed")
10
+ return event
11
+ }
12
+
13
+ if (!event.sessionID) return event
14
+ if (event.kind === "session" && event.action === "deleted") {
15
+ manager.remove?.(event.sessionID, { reason: "session-deleted" })
16
+ return event
17
+ }
18
+
19
+ manager.observeExternal?.(event.sessionID)
20
+ } catch {}
21
+
22
+ return event
23
+ }