Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7a3774f
fix(ai-hook): support OpenCode v2 plugin lifecycle
WilliamWang1721 Sep 26, 2026
3b05a80
Merge branch 'main' into fix/opencode-v2-hook
WilliamWang1721 Sep 26, 2026
82445a5
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 26, 2026
0349cc0
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 26, 2026
fde173e
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 26, 2026
372770f
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 27, 2026
7702696
Merge branch 'main' into fix/opencode-v2-hook
WilliamWang1721 Sep 27, 2026
dbf325f
Merge branch 'main' into fix/opencode-v2-hook
WilliamWang1721 Sep 27, 2026
3a1c67c
Merge branch 'main' into fix/opencode-v2-hook
WilliamWang1721 Sep 27, 2026
bf479d8
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
c7846af
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
4ee0204
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
d130b28
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
7f75edf
docs(ai-hook): record OpenCode V2 lifecycle
WilliamWang1721 Sep 28, 2026
241ede0
Merge remote-tracking branch 'origin/main' into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
efe96db
Merge upstream main into fix/opencode-v2-hook
WilliamWang1721 Sep 28, 2026
a6d8f76
Merge branch 'main' into fix/opencode-v2-hook
Kuddev Sep 29, 2026
2629291
Merge branch 'main' into fix/opencode-v2-hook
WilliamWang1721 Sep 29, 2026
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
42 changes: 42 additions & 0 deletions .github/workflows/opencode-bridge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: OpenCode notification bridge

on:
push:
paths:
- nebula_app/res/hooks/opencode.js
- scripts/tests/opencode-notification-bridge.test.mjs
- .github/workflows/opencode-bridge.yml
pull_request:
paths:
- nebula_app/res/hooks/opencode.js
- scripts/tests/opencode-notification-bridge.test.mjs
- .github/workflows/opencode-bridge.yml
workflow_dispatch:

permissions:
contents: read

jobs:
bridge:
name: OpenCode bridge (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022, macos-14]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: oven-sh/setup-bun@v2
with:
# Match the runtime used by OpenCode v2.0.18.
bun-version: '1.4.2'
- name: Node bridge regression tests
run: node --test scripts/tests/opencode-notification-bridge.test.mjs
- name: Bun bridge regression tests
run: bun test scripts/tests/opencode-notification-bridge.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# OpenCode V2 notification lifecycle

## Status

Proposed in PR #298; maintainer review pending.

## Context

The generated OpenCode hook must keep the existing V1 server entrypoint while
supporting V2's default plugin definition and async event subscription. Both paths
must feed the same Pebrel notification reducer and helper protocol. A V2 plugin
may be unloaded while its event stream or helper delivery is still pending.

## Evidence

- OpenCode v2.0.18 validates a default plugin definition with an `id` and an
`effect` or `setup` entrypoint: [loader](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/core/src/plugin/module.ts).
- V2's Promise plugin event domain reuses the generated client Event API. In
v2.0.18, `event.subscribe` takes one optional request-options argument; it has
no query argument: [plugin event type](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/plugin/src/promise/event.ts),
[generated event client](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/client/src/promise/generated/client.ts#L1660).
- The bridge's regression suite exercises the installed ESM export, V1 and V2
event mapping, cleanup, and helper argument delivery.

## Decision

Keep one default object with `id`, the V1 `server` entrypoint, and the V2 `setup`
entrypoint. `setup` subscribes with
`ctx.event.subscribe({ signal: controller.signal })`, translates only the required
V2 lifecycle events, and forwards them through the existing reducer. Its cleanup
aborts the subscription and helper process and waits for queued delivery to end.

## Rejected alternatives

- Separate V1 and V2 hook files would duplicate lifecycle mapping and risk two
active bridge instances.
- Passing a query and request options separately does not match the pinned V2
event API; other generated client operations use that two-argument shape, but
`event.subscribe` does not.
- Replacing the reducer with V2-specific notification state would create a
second authority for session lifecycle and ordering.

## Consequences

V1 and V2 remain adapters over the same notification payload and process helper.
V2 cleanup owns both the stream and any pending helper work. When OpenCode changes
its plugin export or event schema, the generated hook and focused bridge workflow
must be checked against that release before widening the supported version range.

## Validation

The Node/Bun workflow runs the focused bridge suite on Linux, Windows, and macOS.
The suite covers the installed module and cleanup contract; it does not claim a
full OpenCode UI or SSH end-to-end run.

## Supersedes

None.

## Revisit when

Revisit if V1 support is dropped, the pinned V2 event API changes, or the Pebrel
helper protocol no longer owns notification delivery and ordering.
89 changes: 79 additions & 10 deletions nebula_app/res/hooks/opencode.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Pebrel ↔ opencode bridge — AUTO-GENERATED by Pebrel, do not edit.
// Forwards turn lifecycle to Pebrel's sidebar (icon + spinner + toasts).
// Inert outside Pebrel (no PEBREL_HOOK_EXE or legacy alias in the environment).
export const PebrelNotify = async ({ $, directory, worktree }) => {
import { execFile } from "node:child_process"

const PebrelNotify = async ({ directory, worktree }) => {
const hook = process.env.PEBREL_HOOK_EXE ?? process.env.NEBULA_HOOK_EXE
if (!hook) return {}
const controller = new AbortController()
// Provider events interleave sessions. Permission waits keep a turn active;
// a child session's idle must not consume the primary session's completion.
const turns = new Map()
Expand All @@ -30,13 +33,23 @@ export const PebrelNotify = async ({ $, directory, worktree }) => {
obj.bridge_sequence = bridgeSequence
if (!obj.event_id) obj.event_id = `${sessionId || "pending"}:${bridgeSequence}`
const payload = JSON.stringify(obj)
// 看门狗:一个卡住的 helper 会把整条链堵死,包括最后那个 idle——badge
// 就永远停在转圈上。超时后不再等它,直接放行下一次发送。
// Run without a shell and bound a stuck helper so it cannot block idle.
sendChain = sendChain
.then(() => Promise.race([
$`${hook} opencode ${payload}`.quiet().nothrow(),
new Promise((resolve) => setTimeout(resolve, WATCHDOG_MS)),
]))
.then(() => {
if (controller.signal.aborted) return
let watchdog
return Promise.race([
new Promise((resolve) => {
execFile(hook, ["opencode", payload], {
timeout: WATCHDOG_MS,
killSignal: "SIGKILL",
windowsHide: true,
signal: controller.signal,
}, () => resolve())
}),
new Promise((resolve) => { watchdog = setTimeout(resolve, WATCHDOG_MS) }),
]).finally(() => clearTimeout(watchdog))
})
.catch(() => {})
}
catch (_) {}
Expand All @@ -54,12 +67,16 @@ export const PebrelNotify = async ({ $, directory, worktree }) => {
kind: "attention",
event_id: requestId ? `${sessionId || "pending"}:permission:${requestId}` : undefined,
message: request.title || request.message || request.reason || request.description || "",
permission_or_tool: permissionType || request.type || request.tool || "permission",
permission_or_tool: permissionType || request.action || request.type || request.tool || "permission",
cwd: request.directory || request.cwd || "",
context: request,
})
}
return {
dispose: async () => {
controller.abort()
await sendChain
},
event: async ({ event }) => {
const t = event && event.type
const props = (event && event.properties) || {}
Expand Down Expand Up @@ -92,8 +109,8 @@ export const PebrelNotify = async ({ $, directory, worktree }) => {
} else if (t === "session.deleted") {
turns.delete(sessionId || "pending")
send({ kind: "session-end" })
}
},
}
},
// OpenCode exposes a named permission hook. Keeping
// this separate callback is what guarantees delivery of the full request
// context; merely looking for an event named permission.ask is insufficient.
Expand All @@ -102,3 +119,55 @@ export const PebrelNotify = async ({ $, directory, worktree }) => {
},
}
}

// The shared object entrypoint is supported by OpenCode >= 1.18.29 and V2.
// Keep only one export: V1 loaders otherwise initialize the bridge twice.
export default {
id: "pebrel",
server: PebrelNotify,
async setup(ctx) {
const bridge = await PebrelNotify(ctx.location)
if (!bridge.event) return
const controller = new AbortController()
Comment thread
WilliamWang1721 marked this conversation as resolved.
const consume = (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (controller.signal.aborted) break
// OpenCode routes unlocated session events to their owning location.
// Reject explicitly foreign events without dropping those lifecycles.
if (event.location && event.location.directory !== ctx.location.directory) continue
const data = event.data
let type
let properties = data
switch (event.type) {
case "session.execution.started":
type = "message.updated"
properties = { info: { role: "user", sessionID: data.sessionID, id: event.id } }
break
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted":
type = "session.idle"
break
case "permission.asked":
type = "permission.ask"
break
case "session.tool.success":
case "session.tool.failed":
type = "tool.execute.after"
break
case "session.deleted":
type = "session.deleted"
break
default:
continue
}
await bridge.event({ event: { type, properties } })
}
})().catch(() => {}) // Notification failures must not fail the Agent.
return async () => {
controller.abort()
await bridge.dispose()
await consume
}
},
}
Loading
Loading