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
173 changes: 173 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: CI

on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
CI: true

jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Run typecheck
run: bun typecheck

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Run oxlint
run: bun lint
- name: Check formatting
run: npx prettier --check "packages/opencode/src/**/*.ts" "packages/opencode/test/**/*.ts"

test:
name: Test (shard ${{ matrix.shard }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: ["1/4", "2/4", "3/4", "4/4"]
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Configure git identity
run: |
git config --global user.email "ci@mimo.ai"
git config --global user.name "mimo-ci"
- name: Run unit tests (shard ${{ matrix.shard }})
timeout-minutes: 8
working-directory: packages/opencode
run: bun run test:ci --shard ${{ matrix.shard }}
- name: Upload JUnit
if: always()
uses: actions/upload-artifact@v7
with:
name: junit-shard-${{ strategy.job-index }}
path: packages/opencode/.artifacts/unit/junit.xml

security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Install skylos
run: pip install skylos
- name: Run skylos security scan
run: skylos suite packages/opencode/src/plugin --json || true
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: skylos-results
path: packages/opencode/.artifacts/security/

quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Install repowise
run: pip install repowise
- name: Run repowise health
run: repowise health packages/opencode/src/plugin --json || true
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: repowise-results
path: packages/opencode/.artifacts/quality/

# Multi-language support: detect and test additional languages
python:
name: Python (if present)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for Python files
id: check
run: |
if find . -name "pyproject.toml" -o -name "setup.py" -o -name "requirements.txt" | grep -v node_modules | head -1; then
echo "found=true" >> $GITHUB_OUTPUT
fi
- name: Setup Python
if: steps.check.outputs.found == 'true'
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Python deps
if: steps.check.outputs.found == 'true'
run: |
pip install ruff mypy pytest
- name: Run ruff
if: steps.check.outputs.found == 'true'
run: ruff check .
- name: Run mypy
if: steps.check.outputs.found == 'true'
run: mypy . --ignore-missing-imports
- name: Run pytest
if: steps.check.outputs.found == 'true'
run: python -m pytest tests/ -x --timeout=60 || true

rust:
name: Rust (if present)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for Cargo.toml
id: check
run: |
if find . -name "Cargo.toml" | grep -v node_modules | head -1; then
echo "found=true" >> $GITHUB_OUTPUT
fi
- name: Setup Rust
if: steps.check.outputs.found == 'true'
uses: dtolnay/rust-toolchain@stable
- name: Cargo check
if: steps.check.outputs.found == 'true'
run: cargo check
- name: Cargo test
if: steps.check.outputs.found == 'true'
run: cargo test --no-fail-fast || true

go:
name: Go (if present)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for go.mod
id: check
run: |
if find . -name "go.mod" | grep -v node_modules | head -1; then
echo "found=true" >> $GITHUB_OUTPUT
fi
- name: Setup Go
if: steps.check.outputs.found == 'true'
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Go vet
if: steps.check.outputs.found == 'true'
run: go vet ./...
- name: Go test
if: steps.check.outputs.found == 'true'
run: go test ./... || true
21 changes: 0 additions & 21 deletions .github/workflows/lint.yml

This file was deleted.

47 changes: 0 additions & 47 deletions .github/workflows/test.yml

This file was deleted.

21 changes: 0 additions & 21 deletions .github/workflows/typecheck.yml

This file was deleted.

56 changes: 25 additions & 31 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,8 @@ export interface Interface {
readonly list: () => Effect.Effect<Hooks[]>
readonly init: () => Effect.Effect<void>
readonly reloadFileHooks: () => Effect.Effect<void>
readonly triggerActorPreStop: (
input: ActorPreStopInput,
) => Effect.Effect<ActorStopAggregatedDecision>
readonly triggerActorPostStop: (
input: ActorPostStopInput,
) => Effect.Effect<ActorStopAggregatedDecision>
readonly triggerActorPreStop: (input: ActorPreStopInput) => Effect.Effect<ActorStopAggregatedDecision>
readonly triggerActorPostStop: (input: ActorPostStopInput) => Effect.Effect<ActorStopAggregatedDecision>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
Expand Down Expand Up @@ -175,19 +171,14 @@ function getLegacyPlugins(mod: Record<string, unknown>) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
if (!plugin) continue
result.push(plugin)
}

return result
}

async function applyPlugin(
load: PluginLoader.Loaded,
input: PluginInput,
hooks: Hooks[],
hooksWithMeta: HookEntry[],
) {
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[], hooksWithMeta: HookEntry[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
Expand All @@ -204,9 +195,7 @@ async function applyPlugin(

for (const server of getLegacyPlugins(load.mod)) {
const fnName = (server as { name?: string }).name
const pluginName = fnName && fnName !== "default" && fnName !== ""
? fnName
: (load.pkg?.pkg ?? load.spec)
const pluginName = fnName && fnName !== "default" && fnName !== "" ? fnName : (load.pkg?.pkg ?? load.spec)
const hookObj = await server(input, load.options)
hooks.push(hookObj)
hooksWithMeta.push({
Expand Down Expand Up @@ -460,22 +449,28 @@ export const layer = Layer.effect(
const tmpFile = `${match}.${Date.now()}.mjs`
await Bun.write(tmpFile, blob)
try {
return await import(tmpFile) as Record<string, unknown>
return (await import(tmpFile)) as Record<string, unknown>
} finally {
fs.promises.unlink(tmpFile).catch(() => {})
}
},
catch: (err) => err,
}).pipe(Effect.catch((err) => {
log.error("failed to load file hook", { path: match, error: errorMessage(err) })
return Effect.succeed(undefined)
}))
}).pipe(
Effect.catch((err) => {
log.error("failed to load file hook", { path: match, error: errorMessage(err) })
return Effect.succeed(undefined)
}),
)
if (!mod) continue
const hookObj: Hooks = (mod.default ?? mod) as Hooks
if (hookObj && typeof hookObj === "object") {
const name = path.basename(match, path.extname(match))
hooks.push(hookObj)
meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` })
meta.push({
hook: hookObj,
pluginName: `file:${name}`,
hookIDFor: (event: string) => `file:${name}#${event}`,
})
log.info("loaded file hook", { path: match, name })
}
}
Expand Down Expand Up @@ -560,8 +555,7 @@ export const layer = Layer.effect(
if (!reg) continue

const fn = typeof reg === "function" ? reg : reg.run
const matcher: ActorMatcher | undefined =
typeof reg === "function" ? undefined : reg.matcher
const matcher: ActorMatcher | undefined = typeof reg === "function" ? undefined : reg.matcher

if (!matchesActor(matcher, input)) {
yield* bus.publish(HookEvent.Executed, {
Expand Down Expand Up @@ -594,7 +588,11 @@ export const layer = Layer.effect(
Effect.tapError((err) =>
Effect.gen(function* () {
hookOutcome = "error"
log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err })
log.error(`${eventName} hook failed`, {
pluginName: entry.pluginName,
hookID: entry.hookIDFor(eventName),
error: err,
})
yield* bus.publish(Session.Event.Error, {
sessionID: input.sessionID as SessionID,
error: new NamedError.Unknown({
Expand Down Expand Up @@ -640,15 +638,11 @@ export const layer = Layer.effect(
return aggregated
})

const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (
input: ActorPreStopInput,
) {
const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (input: ActorPreStopInput) {
return yield* aggregateDecision(input, "actor.preStop")
})

const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (
input: ActorPostStopInput,
) {
const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (input: ActorPostStopInput) {
return yield* aggregateDecision(input, "actor.postStop")
})

Expand Down
Loading