@bybrawe/opencode-loop 0.5.27 → 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.
- package/package.json +6 -3
- package/scripts/comprehensive-test.mjs +18 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +16 -2
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2278 -1918
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- 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
|
+
}
|