Skip to content
Merged
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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Choose the instructions that match the CLI you run:
| OpenCode version | How to identify it | Instructions |
| --- | --- | --- |
| OpenCode 1 stable | You run `opencode` and `opencode --version` prints `1.x` | [OpenCode 1](#opencode-1-stable) |
| OpenCode 2 preview | You run `opencode2` | [OpenCode 2](#opencode-2-preview) |
| OpenCode 2 | You run `opencode` and `opencode --version` prints `2.x` | [OpenCode 2](#opencode-2) |

Do not mix the configuration formats. OpenCode 1 uses `plugin` and `tui.json`; OpenCode 2 uses `plugins` and the global `~/.config/opencode/cli.json`.

Expand Down Expand Up @@ -64,9 +64,15 @@ OpenCode detects both package entrypoints and writes the plugin into the server
}
```

### OpenCode 2 Preview
### OpenCode 2

This plugin supports OpenCode 2 preview `0.0.0-next-17055` while remaining compatible with OpenCode 1. Add the package to both V2 plugin lists.
This plugin supports OpenCode 2 (`2.x`) while remaining compatible with OpenCode 1.

```bash
opencode plugin add @prevalentware/opencode-loop-plugin
```

For manual installation, add the package to both V2 plugin lists.

`opencode.json`:

Expand All @@ -86,7 +92,7 @@ This plugin supports OpenCode 2 preview `0.0.0-next-17055` while remaining compa

OpenCode 2 does not read the V1 `tui.json` file. The server entrypoint is loaded from `opencode.json`; the sidebar and palette integration are loaded from the global `~/.config/opencode/cli.json`.

The OpenCode 2 plugin API is still in preview. This release targets the exact preview above; later previews may require a plugin update. V2 supports the `/loop` command, all loop tools, persistent state, idle scheduling, dynamic loops, Plan-mode safety, session cleanup, and TUI sidebar/palette integration. The dedicated V1 compaction-context hook has no V2 equivalent in this preview. V2 still injects the active-loop system reminder before model dispatch, but cannot append the separate loop block directly to a compaction operation.
V2 supports the `/loop` command, all loop tools, persistent state, idle scheduling, dynamic loops, Plan-mode safety, session cleanup, and TUI sidebar/palette integration. The plugin does not yet integrate the V2 compaction hook. V2 still injects the active-loop system reminder before model dispatch, but cannot append the separate loop block directly to a compaction operation.

## Usage

Expand Down
552 changes: 503 additions & 49 deletions bun.lock

Large diffs are not rendered by default.

51 changes: 36 additions & 15 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@ function v2Log(level, message, extra) {
} catch {}
}
async function setupV2(context) {
const abortController = new AbortController;
const options = context.options ?? {};
const registerCommand = options.register_command ?? true;
const commandName = commandNameFromOptions(options);
Expand Down Expand Up @@ -1380,21 +1381,41 @@ async function setupV2(context) {
const dynamicPending = new Map;
const registrations = [];
const isRestrictedAgent = (agent) => typeof agent === "string" && restrictedAgents.has(agent.trim().toLowerCase());
const pendingIdleChecks = new Map;
async function isSessionBusy(sessionID) {
const session = context.session;
if (typeof session.active !== "function")
return busySessions.has(sessionID);
try {
const active = await session.active();
const busy = Object.hasOwn(active, sessionID);
if (busy)
busySessions.add(sessionID);
else
busySessions.delete(sessionID);
return busy;
} catch {
return busySessions.has(sessionID);
if (abortController.signal.aborted)
return true;
let check = pendingIdleChecks.get(sessionID);
if (!check) {
check = Promise.resolve().then(() => context.session.wait({ sessionID })).then(() => true, () => false);
pendingIdleChecks.set(sessionID, check);
const current = check;
check.then(() => {
if (pendingIdleChecks.get(sessionID) === current)
pendingIdleChecks.delete(sessionID);
});
}
const idle = await new Promise((resolve) => {
let finished = false;
const finish = (value) => {
if (finished)
return;
finished = true;
clearTimeout(timer);
abortController.signal.removeEventListener("abort", aborted);
resolve(value && !abortController.signal.aborted);
};
const aborted = () => finish(false);
const timer = setTimeout(() => finish(false), 1000);
abortController.signal.addEventListener("abort", aborted, { once: true });
if (abortController.signal.aborted)
finish(false);
else
check.then(finish);
});
if (idle)
busySessions.delete(sessionID);
return !idle;
}
function cancelTimer(loopID) {
const timer = timers.get(loopID);
Expand All @@ -1415,7 +1436,7 @@ async function setupV2(context) {
try {
const session = await context.session.get({ sessionID });
const location = context.location;
return session.id === sessionID && (!location || session.location.directory === location.directory && session.location.workspaceID === location.workspaceID);
return session.id === sessionID && (!location || session.location.directory === location.directory && session.projectID === location.project.id);
} catch {
return false;
}
Expand Down Expand Up @@ -1752,7 +1773,6 @@ async function setupV2(context) {
}
}, SESSION_LEASE_HEARTBEAT_MS);
leaseHeartbeat.unref?.();
const abortController = new AbortController;
let eventIterator;
const consumer = (async () => {
const subscription = context.event.subscribe({ signal: abortController.signal });
Expand All @@ -1775,6 +1795,7 @@ async function setupV2(context) {
await Promise.all(ownershipQueues.values());
clearInterval(leaseHeartbeat);
abortController.abort();
pendingIdleChecks.clear();
for (const timer of timers.values())
clearTimeout(timer);
timers.clear();
Expand Down
14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,25 @@
},
"dependencies": {
"@opencode-ai/plugin": "^1.17.1",
"@opentui/solid": ">=0.4.0 <0.6.0",
"@opentui/solid": "0.5.10",
"effect": "^3.21.2",
"solid-js": "1.9.12",
"zod": "4.1.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@opencode-ai/plugin-v2": "npm:@opencode-ai/plugin@0.0.0-beta-18593",
"@opencode-ai/theme": "0.0.0-beta-18593",
"@opentui/core": "0.5.9",
"@opencode/plugin": "2.0.16",
"@opencode/client": "2.0.16",
"@opencode/theme": "2.0.16",
"@opentui/core": "0.5.10",
"@types/bun": "^1.3.13",
"eslint": "^10.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.2"
"typescript-eslint": "^8.59.2",
"@opencode/schema": "2.0.16"
},
"engines": {
"opencode": ">=1.17.1"
"opencode": ">=1.17.1 || >=2.0.0"
},
"publishConfig": {
"access": "public"
Expand Down
53 changes: 36 additions & 17 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Config, Plugin } from "@opencode-ai/plugin"
import type * as PluginV2 from "@opencode-ai/plugin-v2"
import type { Info as ToolV2Info } from "@opencode-ai/plugin-v2/promise/tool"
import type { Tool as ToolSchema } from "@opencode-ai/schema/tool"
import type * as PluginV2 from "@opencode/plugin"
import type { Info as ToolV2Info } from "@opencode/plugin/promise/tool"
import type { Tool as ToolSchema } from "@opencode/schema/tool"
import { randomUUID } from "node:crypto"
import { z } from "zod"
import {
Expand Down Expand Up @@ -714,6 +714,7 @@ type LoopServices = {
}

async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugin.Cleanup> {
const abortController = new AbortController()
const options = (context.options ?? {}) as Options
const registerCommand = options.register_command ?? true
const commandName = commandNameFromOptions(options)
Expand Down Expand Up @@ -752,20 +753,38 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
const isRestrictedAgent = (agent: string | null | undefined) =>
typeof agent === "string" && restrictedAgents.has(agent.trim().toLowerCase())

const pendingIdleChecks = new Map<string, Promise<boolean>>()

async function isSessionBusy(sessionID: string) {
const session = context.session as typeof context.session & {
active?: () => Promise<Record<string, { type: "running" }>>
}
if (typeof session.active !== "function") return busySessions.has(sessionID)
try {
const active = await session.active()
const busy = Object.hasOwn(active, sessionID)
if (busy) busySessions.add(sessionID)
else busySessions.delete(sessionID)
return busy
} catch {
return busySessions.has(sessionID)
if (abortController.signal.aborted) return true
let check = pendingIdleChecks.get(sessionID)
if (!check) {
// The stable adapter does not forward request-option signals. Reuse one
// native wait per session while bounding our own scheduler's await.
check = Promise.resolve().then(() => context.session.wait({ sessionID })).then(() => true, () => false)
pendingIdleChecks.set(sessionID, check)
const current = check
void check.then(() => {
if (pendingIdleChecks.get(sessionID) === current) pendingIdleChecks.delete(sessionID)
})
}
const idle = await new Promise<boolean>((resolve) => {
let finished = false
const finish = (value: boolean) => {
if (finished) return
finished = true
clearTimeout(timer)
abortController.signal.removeEventListener("abort", aborted)
resolve(value && !abortController.signal.aborted)
}
const aborted = () => finish(false)
const timer = setTimeout(() => finish(false), 1000)
abortController.signal.addEventListener("abort", aborted, { once: true })
if (abortController.signal.aborted) finish(false)
else void check.then(finish)
})
if (idle) busySessions.delete(sessionID)
return !idle
}

function cancelTimer(loopID: string) {
Expand All @@ -787,7 +806,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
const session = await context.session.get({ sessionID })
const location = context.location
return session.id === sessionID && (!location ||
(session.location.directory === location.directory && session.location.workspaceID === location.workspaceID))
(session.location.directory === location.directory && session.projectID === location.project.id))
} catch {
return false
}
Expand Down Expand Up @@ -1131,7 +1150,6 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
}, SESSION_LEASE_HEARTBEAT_MS)
leaseHeartbeat.unref?.()

const abortController = new AbortController()
let eventIterator: AsyncIterator<unknown> | undefined
const consumer = (async () => {
const subscription = context.event.subscribe({ signal: abortController.signal })
Expand All @@ -1153,6 +1171,7 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
await Promise.all(ownershipQueues.values())
clearInterval(leaseHeartbeat)
abortController.abort()
pendingIdleChecks.clear()
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
dynamicPending.clear()
Expand Down
8 changes: 4 additions & 4 deletions src/tui.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { Plugin as TuiPluginV2 } from "@opencode-ai/plugin-v2/tui"
import type { SessionMessageInfo } from "@opencode-ai/client"
import type { Plugin as TuiPluginV2 } from "@opencode/plugin/tui"
import type { SessionMessageInfo } from "@opencode/client"
import { createElement, insert, setProp } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js"

Expand Down Expand Up @@ -458,9 +458,9 @@ function LoopSidebarV2(api: TuiPluginV2.Context, sessionID: string) {
const loops = open()
if (loops.length === 0) return null
return box({}, [
text({ fg: api.theme.text.default }, ["Loops"]),
text({ fg: api.theme.text.base }, ["Loops"]),
...loops.map((loop) =>
text({ fg: api.theme.text.subdued }, [
text({ fg: api.theme.text.muted }, [
`${loop.status === "paused" ? "paused " : ""}${loopLine(loop, nowMs())}`,
]),
),
Expand Down
64 changes: 54 additions & 10 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function controlledStream() {

type MockContext = {
options: Record<string, unknown>
location?: { directory: string; workspaceID?: string }
location?: { directory: string; project: { id: string } }
activeSessions: Set<string>
unavailableSessions: Set<string>
promptCalls: Array<{
Expand All @@ -99,9 +99,9 @@ type MockContext = {
transform: (callback: (draft: ToolDraft) => void) => Promise<Registration>
}
session: {
get: (input: { sessionID: string }) => Promise<{ id: string; location?: { directory: string; workspaceID?: string } }>
get: (input: { sessionID: string }) => Promise<{ id: string; projectID?: string; location?: { directory: string } }>
hook: (name: string, callback: (input: unknown) => void) => Promise<Registration>
active: () => Promise<Record<string, { type: "running" }>>
wait: (input: { sessionID: string }, options?: { signal?: AbortSignal }) => Promise<void>
prompt: (input: {
sessionID: string
delivery?: "steer" | "queue"
Expand Down Expand Up @@ -157,7 +157,9 @@ function makeMockContext(options: Record<string, unknown> = {}): MockContext {
hooks[name] = callback
return registration(`session.hook:${name}`)
},
active: async () => Object.fromEntries([...activeSessions].map((sessionID) => [sessionID, { type: "running" as const }])),
wait: async ({ sessionID }) => {
if (activeSessions.has(sessionID)) throw new Error("session still busy")
},
prompt: async (input) => {
promptCalls.push(input)
return { id: "pending_1" }
Expand Down Expand Up @@ -375,8 +377,8 @@ test("V2 authoritative session state overrides a stale busy event", async () =>
contentOf(await loopTool(mock, "create_loop").execute({ instruction: "say tick", interval: "1s" }, toolContext())),
) as { created: string }

// The idle event was lost, but OpenCode's active-session API is authoritative
// and no longer lists this session as running.
// The idle event was lost, but OpenCode's native idle barrier is authoritative
// and now resolves for this session.
await waitFor(() => mock.promptCalls.length >= 1)
expect(mock.promptCalls[0]?.sessionID).toBe("ses_v2")
await waitFor(async () => (await getLoop(created.created))?.runCount === 1)
Expand Down Expand Up @@ -666,12 +668,15 @@ test("V2 keep-alive recovery retains bounded failure pause and explicit resume",
})


test("V2 keep-alive recovery refuses another directory or workspace", async () => {
test("V2 keep-alive recovery refuses another directory or project", async () => {
const loop = await createLoop("ses_foreign", { prompt: "tick", intervalMs: 1, keepAlive: true })
for (const location of [{ directory: "/other" }, { directory: "/current", workspaceID: "other" }]) {
for (const session of [
{ location: { directory: "/other" }, projectID: "current" },
{ location: { directory: "/current" }, projectID: "other" },
]) {
const mock = makeMockContext()
mock.location = { directory: "/current" }
mock.session.get = async ({ sessionID }) => ({ id: sessionID, location })
mock.location = { directory: "/current", project: { id: "current" } }
mock.session.get = async ({ sessionID }) => ({ id: sessionID, ...session })
const cleanup = await plugin.setup(mock as never)
await sleep(200)
expect(mock.promptCalls).toHaveLength(0)
Expand All @@ -694,3 +699,42 @@ test("V2 concurrent recovery contexts deliver a keep-alive iteration once", asyn
await cleanFirst()
await cleanSecond()
})


test("stable V2 recovered keep-alive uses its native idle barrier without active()", async () => {
const loop = await createLoop("ses_busy", { prompt: "tick", intervalMs: 100, keepAlive: true, maxRuns: 1 })
const mock = makeMockContext()
expect(mock.session).not.toHaveProperty("active")
mock.activeSessions.add("ses_busy")
const cleanup = await plugin.setup(mock as never)
await waitFor(async () => (await getLoop(loop.id))?.lastResult === "skipped_busy")
expect(mock.promptCalls).toHaveLength(0)
mock.activeSessions.delete("ses_busy")
await waitFor(async () => (await getLoop(loop.id))?.status === "completed")
expect(mock.promptCalls).toHaveLength(1)
mock.stream.end()
await cleanup()
})


test("V2 bounds native wait even when the stable adapter ignores cancellation", async () => {
const loop = await createLoop("ses_wait", { prompt: "tick", intervalMs: 100, keepAlive: true })
const mock = makeMockContext()
let calls = 0
let release!: () => void
let nativeFinished = false
const pending = new Promise<void>((resolve) => { release = resolve }).then(() => { nativeFinished = true })
mock.session.wait = async () => { calls += 1; await pending }
const cleanup = await plugin.setup(mock as never)
await waitFor(async () => (await getLoop(loop.id))?.lastResult === "skipped_busy")
expect(mock.promptCalls).toHaveLength(0)
await sleep(200)
expect(calls).toBe(1)
mock.stream.end()
await cleanup()
expect(nativeFinished).toBe(false)
release()
await sleep(50)
expect(mock.promptCalls).toHaveLength(0)
expect((await getLoop(loop.id))?.runCount).toBe(0)
})
Loading
Loading