Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 15 additions & 52 deletions scripts/v2-plugin-sentinel-test.mjs
Original file line number Diff line number Diff line change
@@ -1,78 +1,41 @@
import assert from "node:assert/strict"
import plugin, { OPENCODE_LOOP_V2_PLUGIN_ID } from "../src/source/opencode2/experimental.js"
import {
OPENCODE_LOOP_V2_REQUIRED_COMMANDS,
OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS,
inspectOpenCode2CommandDraft,
inspectOpenCode2Context,
inspectOpenCode2CommandFiles,
openCode2LoopRuntimeStatus,
} from "../src/source/opencode2/capabilities.js"

assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID)
assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental")
assert.equal(typeof plugin.setup, "function")
assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, [
"command.add",
"session.events",
"session.prompt",
])
assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["command.files", "session.events", "session.prompt"])

let transforms = 0
let registered
const currentContext = {
const context = {
command: {
transform: async (callback) => {
transforms += 1
registered = callback
return { dispose: async () => {} }
},
},
}
await plugin.setup(currentContext)
assert.equal(transforms, 1)
await plugin.setup(context)
assert.equal(typeof registered, "function")

const currentDraft = {
list: () => [],
get: () => undefined,
const commands = OPENCODE_LOOP_V2_REQUIRED_COMMANDS.map((name) => ({ name }))
const draft = {
list: () => commands,
get: (name) => commands.find((item) => item.name === name),
update: () => {},
remove: () => {},
}
await registered(currentDraft)

assert.deepEqual(inspectOpenCode2Context(currentContext), {
commandTransform: true,
sessionEvents: false,
sessionPrompt: false,
})
assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
list: true,
get: true,
update: true,
remove: true,
add: false,
})
assert.deepEqual(openCode2LoopRuntimeStatus(currentContext, currentDraft).blockers, [
"command.add",
"session.events",
"session.prompt",
])
assert.equal(openCode2LoopRuntimeStatus(currentContext, currentDraft).ready, false)

const futureContext = {
command: { transform: async () => ({ dispose: async () => {} }) },
event: { subscribe: async () => ({ dispose: async () => {} }) },
session: { prompt: async () => ({}) },
}
const futureDraft = { ...currentDraft, add: () => {} }
assert.deepEqual(openCode2LoopRuntimeStatus(futureContext, futureDraft).blockers, [])
assert.equal(openCode2LoopRuntimeStatus(futureContext, futureDraft).ready, true)

let missingCapabilityFailed = false
try {
await plugin.setup({ command: {} })
} catch (error) {
missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error))
}
assert.equal(missingCapabilityFailed, true)
await registered(draft)
assert.deepEqual(inspectOpenCode2CommandDraft(draft), { list: true, get: true, update: true, remove: true })
assert.equal(inspectOpenCode2CommandFiles(draft).ready, true)
assert.deepEqual(inspectOpenCode2CommandFiles(draft).missing, [])
assert.deepEqual(openCode2LoopRuntimeStatus(context, draft).blockers, ["session.events", "session.prompt"])

await plugin.setup({})
console.log("OpenCode 2 plugin sentinel contract passed")
63 changes: 59 additions & 4 deletions src/source/opencode2/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,30 @@ function frozenRecord(value) {
return Object.freeze(value)
}

function commandName(value) {
if (!value || typeof value !== "object") return undefined
for (const key of ["name", "id", "command"]) {
const candidate = value[key]
if (typeof candidate === "string" && candidate.trim()) return candidate.trim()
}
return undefined
}

export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "command-files"

export const OPENCODE_LOOP_V2_REQUIRED_COMMANDS = Object.freeze([
"loop",
"loop-now",
"loop-pause",
"loop-resume",
"loop-stop",
"loop-status",
"loop-clear",
"loop-help",
])

export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
"command.add",
"command.files",
"session.events",
"session.prompt",
])
Expand All @@ -31,17 +53,49 @@ export function inspectOpenCode2CommandDraft(draft) {
get: hasFunction(draft, "get"),
update: hasFunction(draft, "update"),
remove: hasFunction(draft, "remove"),
add: hasFunction(draft, "add") || hasFunction(draft, "create"),
})
}

export function inspectOpenCode2CommandFiles(draft, requiredCommands = OPENCODE_LOOP_V2_REQUIRED_COMMANDS) {
const command = inspectOpenCode2CommandDraft(draft)
const required = [...requiredCommands].map((value) => String(value || "").trim()).filter(Boolean)
const available = new Set()

if (command.list) {
try {
for (const item of draft.list() || []) {
const name = commandName(item)
if (name) available.add(name)
}
} catch {}
}

if (command.get) {
for (const name of required) {
if (available.has(name)) continue
try {
if (draft.get(name)) available.add(name)
} catch {}
}
}

const missing = required.filter((name) => !available.has(name))
return frozenRecord({
source: OPENCODE_LOOP_V2_COMMAND_SOURCE,
ready: missing.length === 0,
required: Object.freeze(required),
available: Object.freeze([...available].filter((name) => required.includes(name))),
missing: Object.freeze(missing),
})
}

export function openCode2LoopRuntimeStatus(ctx, commandDraft) {
const context = inspectOpenCode2Context(ctx)
const command = inspectOpenCode2CommandDraft(commandDraft)
const commandFiles = inspectOpenCode2CommandFiles(commandDraft)
const blockers = []

if (!context.commandTransform) blockers.push("command.transform")
if (!command.add) blockers.push("command.add")
if (!commandFiles.ready) blockers.push("command.files")
if (!context.sessionEvents) blockers.push("session.events")
if (!context.sessionPrompt) blockers.push("session.prompt")

Expand All @@ -50,5 +104,6 @@ export function openCode2LoopRuntimeStatus(ctx, commandDraft) {
blockers: Object.freeze(blockers),
context,
command,
commandFiles,
})
}
16 changes: 9 additions & 7 deletions src/source/opencode2/experimental.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { define } from "@opencode-ai/plugin/v2/promise"
import { inspectOpenCode2Context } from "./capabilities.js"
import { inspectOpenCode2CommandFiles, inspectOpenCode2Context } from "./capabilities.js"

export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"

export async function registerOpenCode2CommandProbe(ctx) {
if (!inspectOpenCode2Context(ctx).commandTransform) return undefined
return await ctx.command.transform((draft) => {
inspectOpenCode2CommandFiles(draft)
})
}

export const OpenCodeLoopV2ExperimentalPlugin = define({
id: OPENCODE_LOOP_V2_PLUGIN_ID,
async setup(ctx) {
const capabilities = inspectOpenCode2Context(ctx)
if (!capabilities.commandTransform) {
throw new Error("OpenCode 2 command.transform capability is unavailable")
}

await ctx.command.transform(() => {})
await registerOpenCode2CommandProbe(ctx)
},
})

Expand Down