Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
45c3da7
feat: add OpenCode 2 beta adapter
shanebishop1 Jul 18, 2026
a1a1510
fix: verify V2 against isolated host
shanebishop1 Jul 20, 2026
6301f5e
ci: publish GitHub releases from tags
shanebishop1 Jul 24, 2026
0a4b975
Merge pull request #7 from shanebishop1/fix/v1-e2e-confidence
shanebishop1 Jul 24, 2026
7ba3e24
Merge pull request #8 from shanebishop1/fix/v1-e2e-confidence
shanebishop1 Jul 24, 2026
a167a95
feat: scope session hooks to parent sessions
shanebishop1 Jul 27, 2026
5b2c64b
fix: scope V2 session hooks
shanebishop1 Jul 27, 2026
4fa438b
fix: keep unknown V2 session scope safe
shanebishop1 Jul 27, 2026
ddd589f
Revert "fix: keep unknown V2 session scope safe"
shanebishop1 Jul 27, 2026
63f3aa1
Revert "fix: scope V2 session hooks"
shanebishop1 Jul 27, 2026
9467255
Revert "feat: scope session hooks to parent sessions"
shanebishop1 Jul 27, 2026
e73ad2f
fix: suppress idle hooks during subagent waits
shanebishop1 Jul 27, 2026
1ef9557
fix: suppress V2 idle hooks during subagent waits
shanebishop1 Jul 27, 2026
f8c9ef9
fix: clear subagent state for deleted sessions
shanebishop1 Jul 27, 2026
1e707ab
fix: clear V2 subagent state on session deletion
shanebishop1 Jul 27, 2026
f8a4664
Merge main and refresh OpenCode adapters
shanebishop1 Aug 30, 2026
0c54769
test: exercise current OpenCode hosts
shanebishop1 Aug 30, 2026
a6ab4ce
feat: support V1 and V2 from one package
shanebishop1 Sep 1, 2026
bb5f066
fix: support current V2 local package loading
shanebishop1 Sep 1, 2026
d4664ff
Merge main into feat/opencode-v2
shanebishop1 Sep 1, 2026
f097e9c
Merge branch 'main' into feat/opencode-v2
shanebishop1 Sep 1, 2026
441b052
fix: target stable OpenCode 2 plugin API
shanebishop1 Sep 21, 2026
0d7ed80
test: cover V2 with deterministic and live models
shanebishop1 Sep 21, 2026
8b98114
docs: document stable V2 support and test modes
shanebishop1 Sep 21, 2026
4a1cb7d
Merge main into feat/opencode-v2
shanebishop1 Sep 21, 2026
4f1a4f6
test: allow package builds to finish on CI runners
shanebishop1 Sep 21, 2026
af10cfe
fix: load the plugin by package name in OpenCode V2
shanebishop1 Oct 1, 2026
1882915
fix: stop skipping the plugin on older OpenCode V1 hosts
shanebishop1 Oct 1, 2026
9543851
test: install the plugin by package name across OpenCode hosts
shanebishop1 Oct 1, 2026
bd0458b
docs: describe automatic V1 and V2 support from one package
shanebishop1 Oct 1, 2026
4bf8f35
ci: gate releases on package-name installs across OpenCode hosts
shanebishop1 Oct 1, 2026
75e66f3
test: drive V1 E2E with the deterministic local provider
shanebishop1 Oct 1, 2026
bbc4584
test: use canonical temp paths in host E2E suites
shanebishop1 Oct 1, 2026
056107e
test: run V1 E2E against a long-lived server
shanebishop1 Oct 1, 2026
6248221
docs: keep the README to one line on OpenCode 2 support
shanebishop1 Oct 1, 2026
daa7b99
fix: drop the unused @opencode/plugin dependency
shanebishop1 Oct 1, 2026
d8b8b69
docs: remove the V2 migration research report
shanebishop1 Oct 1, 2026
166821a
chore: drop leftover tsconfig baseUrl
shanebishop1 Oct 1, 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
56 changes: 54 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Build and Test

on:
pull_request:
workflow_dispatch:
push:
branches:
- main
Expand Down Expand Up @@ -41,12 +42,63 @@ jobs:
- name: Test
run: npm test

- name: Build
run: npm run build
- name: Build dual-host package
run: npm run build:all

- name: Verify package
run: npm pack --dry-run

- name: Test packed package in OpenCode 2 with a deterministic provider
run: npm run test:v2:e2e

hosts:
name: Package-name install across OpenCode hosts
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.9"

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Install the packed plugin by name in V1 and V2 hosts
run: npm run test:hosts:e2e

v2-live:
name: V2 live-model E2E
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.9"

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Test packed package with a hosted free model
run: npm run test:v2:e2e:live

verify-windows-agent-config:
runs-on: windows-latest
steps:
Expand Down
21 changes: 20 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,20 @@ jobs:
- name: Pack dry-run
run: npm pack --dry-run

- name: Test packed package in OpenCode 2 with a deterministic provider
run: npm run test:v2:e2e

- name: Test packed package in OpenCode 2 with a hosted free model
run: npm run test:v2:e2e:live

- name: Install the packed plugin by name in V1 and V2 hosts
run: npm run test:hosts:e2e

publish-npm:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
contents: write
id-token: write
steps:
- name: Checkout
Expand All @@ -89,3 +98,13 @@ jobs:

- name: Publish to npm
run: npm publish --access public --provenance

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "GitHub release already exists: $GITHUB_REF_NAME"
else
gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME"
fi
2 changes: 1 addition & 1 deletion .github/workflows/v1-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
run: bun install --frozen-lockfile

- name: Install OpenCode V1
run: bun install --global opencode-ai@1.18.3
run: bun install --global opencode-ai@1.18.29

- name: Run V1 headless E2E tests
run: npm run test:v1:e2e
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ Add to your `opencode.json`:
}
```

This works unchanged on OpenCode 1.x and OpenCode 2 (which also accepts `"plugins"`). On OpenCode 2, hooks cannot show toasts yet; everything else works the same.

## Configuration

### Config Locations
Expand Down Expand Up @@ -415,7 +417,7 @@ Tool-arg matching is exact. This example runs only when the tool arg `path` equa
},
{
"id": "session-idle",
"when": { "event": "session.idle" },
"when": { "event": "session.idle", "excludeSubagentWait": true },
"run": ["notify-user.sh 'Waiting for input'"],
},
],
Expand Down Expand Up @@ -448,6 +450,12 @@ background work remains or that OpenCode is specifically waiting for user input.
If session lookup fails, hooks run without root filtering rather than being
silently dropped.

`excludeSubagentWait` is opt-in. When true, that `session.idle` hook also waits
until all active `task` subagent calls for the root session have completed. Other
idle hooks still run while the parent is waiting on a subagent. This is separate
from `rootSessionOnly`: one filters child-session events, while the other filters
parent idle events during active subagent calls.

---

## Template Placeholders
Expand Down
70 changes: 34 additions & 36 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./dist/entry.js"
23 changes: 17 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,31 @@
"version": "0.7.1",
"description": "OpenCode plugin for declaratively attaching shell commands to tool, subagent, and session events",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist/entry.js",
"types": "dist/entry.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/entry.d.ts",
"import": "./dist/entry.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js"
}
},
"files": [
"dist",
"index.js",
"server.js",
"types"
],
"scripts": {
"build": "tsc",
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"build:all": "npm run build",
"test": "bun test",
"test:v2": "bun test tests/v2.plugin.test.ts tests/v2.package.test.ts",
"test:v2:e2e": "OPENCODE2_E2E=1 bun test tests/e2e.v2.test.ts",
"test:v2:e2e:live": "OPENCODE2_E2E=1 OPENCODE2_E2E_LIVE=1 bun test tests/e2e.v2.test.ts",
"lint": "eslint src tests --ext .ts",
"typecheck": "tsc --noEmit",
"ci:check": "npm run lint && npm run typecheck && npm test",
Expand All @@ -26,7 +36,8 @@
"typecheck:bun-run": "bun run src/index.ts",
"test:v1:e2e": "npm run build && OPENCODE_E2E=1 bun test tests/e2e.v1-headless.test.ts",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"test:hosts:e2e": "OPENCODE_HOSTS_E2E=1 bun test tests/e2e.hosts.test.ts"
},
"keywords": [
"opencode",
Expand Down
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./dist/server.js"
3 changes: 2 additions & 1 deletion src/config/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { mergeConfigs } from "./merge.js";
import { join, dirname } from "path";
import { homedir } from "os";
import { stat } from "fs/promises";
import JsoncParser, { type ParseError } from "jsonc-parser";
import * as JsoncParser from "jsonc-parser";
import type { ParseError } from "jsonc-parser";
import { logger } from "../logging.js";

/**
Expand Down
12 changes: 12 additions & 0 deletions src/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { CommandHooksPlugin } from "./index.js"
import plugin from "./server.js"

/**
* Package root for OpenCode V1 before 1.3.4, which ignores the `./server`
* export and calls every root export as a plugin function. Current hosts
* resolve `./server` first, so they never load this module.
*/
const legacyPlugin = Object.assign((input: PluginInput) => CommandHooksPlugin(input), plugin)

export default legacyPlugin
64 changes: 34 additions & 30 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
* Errors are logged and optionally injected into the session, but never thrown.
*/

import type { OpencodeClient } from "@opencode-ai/sdk"
import { open, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
Expand All @@ -32,6 +31,19 @@ import { interpolateTemplate } from "./execution/template.js"
import { logger } from "./logging.js"
import { compileToolArgGlob, compileToolArgRegex } from "./matcher.js"

export interface HookToast {
title?: string
message: string
variant?: "info" | "success" | "warning" | "error"
duration?: number
}

export interface HookHost {
cwd: string
inject: (sessionId: string, message: string, hookId: string) => Promise<void>
toast: (toast: HookToast) => Promise<void>
}

/**
* Check if a value matches a pattern (string, array of strings, or wildcard)
*/
Expand Down Expand Up @@ -76,7 +88,7 @@ const matchesToolArg = (
*/
export const filterSessionHooks = (
hooks: SessionHook[],
criteria: { event: string; agent: string | undefined }
criteria: { event: string; agent: string | undefined; hasActiveSubagents?: boolean }
): SessionHook[] => {
return hooks.filter((hook) => {
// Normalize session.start to session.created
Expand All @@ -86,6 +98,11 @@ export const filterSessionHooks = (
}

if (normalizedEvent !== criteria.event) return false
if (
normalizedEvent === "session.idle" &&
hook.when.excludeSubagentWait &&
criteria.hasActiveSubagents
) return false

if (hook.when.agent && !criteria.agent) {
logger.debug(
Expand Down Expand Up @@ -223,7 +240,7 @@ const createHookArgsFile = async (
* @returns Promise that resolves when toast is shown
*/
const showToast = async (
client: OpencodeClient,
host: HookHost,
title: string | undefined,
message: string,
variant: "info" | "success" | "warning" | "error" = "info",
Expand All @@ -233,14 +250,7 @@ const showToast = async (
const finalTitle = title || "OpenCode Command Hook"
logger.debug(`Showing toast: title="${finalTitle}", message="${message.substring(0, 50)}...", variant="${variant}", duration=${duration}`)

await client.tui.showToast({
body: {
title: finalTitle,
message,
variant,
duration,
},
})
await host.toast({ title: finalTitle, message, variant, duration })

logger.info(`[toast] ${finalTitle}: ${message}`)
} catch (error) {
Expand All @@ -263,19 +273,15 @@ const showToast = async (
* @returns Promise that resolves when message is injected
*/
const injectMessage = async (
client: OpencodeClient,
host: HookHost,
sessionId: string,
message: string
message: string,
hookId: string,
): Promise<void> => {
try {
logger.debug(`Injecting message into session ${sessionId}`)

await client.session.promptAsync({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: message }],
},
})
await host.inject(sessionId, message, hookId)

logger.info(`[inject] Message injected into session ${sessionId}: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`)
} catch (error) {
Expand All @@ -302,7 +308,7 @@ const injectMessage = async (
const executeHook = async (
hook: ToolHook | SessionHook,
context: HookExecutionContext,
client: OpencodeClient,
host: HookHost,
truncationLimit?: number
): Promise<void> => {
const hookType = isToolHook(hook) ? "tool" : "session"
Expand All @@ -327,10 +333,8 @@ const injectMessage = async (
hook.id,
{
truncateOutput: truncationLimit,
cwd: context.directory,
env: {
OPENCODE_HOOK_ARGS_FILE: argsFile.path,
},
cwd: host.cwd,
env: { OPENCODE_HOOK_ARGS_FILE: argsFile.path },
},
)
} finally {
Expand All @@ -346,7 +350,7 @@ const injectMessage = async (
} else if (hook.run) {
results = await executeCommands(hook.run, hook.id, {
truncateOutput: truncationLimit,
cwd: context.directory,
cwd: host.cwd,
})
}

Expand All @@ -370,7 +374,7 @@ const injectMessage = async (
// If inject is configured, prepare and inject the message
if (hook.inject) {
const message = interpolateTemplate(hook.inject, templateContext)
await injectMessage(client, context.sessionId, message)
await injectMessage(host, context.sessionId, message, hook.id)
}

// If toast is configured, interpolate and show toast notification
Expand All @@ -379,7 +383,7 @@ const injectMessage = async (
const toastMessage = interpolateTemplate(hook.toast.message, templateContext)

await showToast(
client,
host,
toastTitle,
toastMessage,
hook.toast.variant || "info",
Expand All @@ -391,7 +395,7 @@ const injectMessage = async (
logger.error(errorMessage)

try {
await injectMessage(client, context.sessionId, errorMessage)
await injectMessage(host, context.sessionId, errorMessage, hook.id)
} catch (injectionError) {
const injectionErrorMsg =
injectionError instanceof Error
Expand Down Expand Up @@ -452,7 +456,7 @@ const isToolHook = (hook: ToolHook | SessionHook): hook is ToolHook => {
export async function executeHooks(
hooks: (ToolHook | SessionHook)[],
context: HookExecutionContext,
client: OpencodeClient,
host: HookHost,
truncationLimit?: number
): Promise<void> {
try {
Expand All @@ -463,7 +467,7 @@ export async function executeHooks(
// Execute each hook
for (const hook of hooks) {
try {
await executeHook(hook, context, client, truncationLimit)
await executeHook(hook, context, host, truncationLimit)
} catch (error) {
// Catch errors from individual hook execution and continue
// (errors are already logged and injected by the hook execution functions)
Expand Down
Loading
Loading