From 7511aeaf45a9c7ad02f1aaa6c8d1dce91ed9e3c1 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Tue, 25 Aug 2026 12:28:36 +0200 Subject: [PATCH 1/5] feat(plugin): add opencode v2 port with event-driven architecture - Add src/v2/index.ts: full port using Plugin.define + ctx.event.subscribe() - Add docs/v2/migration.md: migration notes and API mapping - Update README.md: v2 callout banner and install instructions Ports from v1 hooks-object API to v2 promise-plugin API: - Event pump via AsyncIterable instead of event hook callback - Flattened SDK calls (session.prompt({sessionID, text})) - Assistant text accumulated from streaming events (no message history access) - New guards: permission-aware pausing, subagent-aware waiting, failure-recovery arming - All detection/recovery features preserved: stall watchdog, failure recovery, tool-call-as-text, ready-to-continue nudges, hallucination loop guard, etc. Tested against opencode2 v0.0.0-beta-18050, @opencode-ai/plugin 0.0.0-next-17403. --- README.md | 26 ++ docs/v2/migration.md | 155 +++++++ src/v2/index.ts | 931 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1112 insertions(+) create mode 100644 docs/v2/migration.md create mode 100644 src/v2/index.ts diff --git a/README.md b/README.md index 56213b3..abb7ec7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ **Plugin for [OpenCode](https://github.com/anomalyco/opencode) that automatically detects and recovers from LLM session failures — stalls, broken tool calls, hallucination loops, stuck subagent parents, and more. Fully silent, zero UI pollution.** +> **OpenCode v2 is here.** This repo now ships both the v1 plugin (`src/index.ts`) +> and a complete v2 port (`src/v2/index.ts`). See [docs/v2/migration.md](docs/v2/migration.md) +> for the full migration notes, or jump to the [Installation](#installation) section. + ## What it does LLM sessions fail in predictable ways. This plugin monitors all sessions and automatically recovers without user intervention. Each recovery path below references the upstream OpenCode issues that motivated it — these are problems not yet resolved in the official project. @@ -303,6 +307,28 @@ With options: } ``` +### OpenCode v2 + +The v2 plugin uses the new `Plugin.define` API with `ctx.event.subscribe()` (AsyncIterable) instead of the v1 hooks-object pattern. Add to your `opencode.json`: + +```jsonc +{ + "plugins": [ + { + "package": "./plugins/auto-resume-v2.ts", + "options": { + "chunkTimeoutMs": 45000, + "maxRetries": 3 + } + } + ] +} +``` + +Or place `src/v2/index.ts` directly in `~/.config/opencode/plugins/` for auto-discovery (no config entry needed). + +Disable via `"-auto-resume.v2"` in `plugins`. Full v2 migration notes: [docs/v2/migration.md](docs/v2/migration.md). + ### Configurable options | Option | Default | Description | diff --git a/docs/v2/migration.md b/docs/v2/migration.md new file mode 100644 index 0000000..e3c52f2 --- /dev/null +++ b/docs/v2/migration.md @@ -0,0 +1,155 @@ +# opencode-auto-resume — OpenCode v2 port: what changed + +> Saved from ~/.config/opencode/plugins/README.md on 2026-08-25. +> Drop this into the PR description (or keep as docs/v2-migration.md upstream). +> Target: https://github.com/Mte90/opencode-auto-resume +> Runtime tested against: opencode2 v0.0.0-beta-18050, @opencode-ai/plugin 0.0.0-next-17403 + +## Summary + +Ports the plugin from the v1 hooks API (`Plugin` factory returning a hooks +object) to the v2 promise-plugin API (`Plugin.define({ id, setup })` + +`ctx.event.subscribe()`). All detection/recovery features are preserved. +Strict-mode typechecked against the real installed `@opencode-ai/plugin` +types; validated by a mocked-context runtime suite covering every recovery +path (12/12 checks). + +## 1. Config key renamed: `plugin` → `plugins` + +```jsonc +// v1 (removed) +{ "plugin": ["some-package", ["./local.ts", { "opt": 1 }]] } + +// v2 +{ + "plugins": [ + "some-package", + { "package": "./local.ts", "options": { "opt": 1 } } + ] +} +``` + +The tuple form `[path, options]` is gone — use the object form with +`package` + `options`. Local paths must start with `./` or `../` and resolve +relative to the config file. Disable directives (`"-plugin-id"`, `"*"`) +still work and are matched against the plugin's exported `id`. + +## 2. Module shape: hooks-object → `{ id, setup }` + +```ts +// v1: export a factory that returns a Hooks object +export const MyPlugin: Plugin = async ({ client, $, directory }) => ({ + event: async ({ event }) => { /* ... */ }, + "tool.execute.before": async (input, output) => { /* ... */ }, +}) + +// v2: default-export Plugin.define with a unique id and a setup fn. +// setup registers long-lived behavior and MAY return a cleanup function, +// which OpenCode awaits on disable/reload/shutdown. +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "acme.thing", // unique — used by disable directives + setup: async (ctx) => { + /* register timers/subscriptions here */ + return () => { /* cleanup */ } + }, +}) +``` + +Legacy single-function modules are still loaded through a compatibility +shim, but new code should target v2 directly. + +## 3. Context capabilities replace most hooks + +The v2 context is essentially a typed server client plus registration APIs: + +| v2 capability | Replaces (v1) | +|---|---| +| `ctx.event.subscribe()` → **AsyncIterable** of events | `event` hook | +| `ctx.session.prompt/interrupt/create/get/command/synthetic/generate` + `ctx.session.hook("context")` | direct SDK calls / chat message hooks | +| `ctx.tool.transform` / `ctx.tool.hook` | `tool` map, `tool.execute.before/after` | +| `ctx.agent.transform` | agent config mutation | +| `ctx.options` | second `options` argument of the v1 factory | +| `ctx.app` | `{ name, version, channel }` only — **no `app.log()`** | + +Notes: +- **Subscribe, don't block**: start the event pump in the background inside + `setup`; do not `await` an infinite loop there. +- **Flattened SDK calls**: nested `{ path, query, body }` envelopes are gone. + Example: `session.prompt({ path: { id }, body: { parts } })` became + `session.prompt({ sessionID, text })`. +- **No history access**: the v2 plugin context does not expose + `message.list()`. Plugins that need assistant text must accumulate it from + streaming events (`session.text.delta`, `session.reasoning.delta`). +- **Logging**: write to the console with a prefix instead of `app.log()`; + opencode captures stdout/stderr into its logs. + +## 4. Event payload shapes changed + +v1 events were `{ type, properties }`; v2 events are flat +`{ type, created, data }`. Useful v2 session events for watchdog-style +plugins: `session.execution.started/succeeded/failed/interrupted`, +`session.step.started/ended/failed`, `session.text.delta`, +`session.reasoning.delta`, `session.tool.called/progress/success/failed`, +`session.retry.scheduled`, `session.idle`, `permission.asked/replied`. + +## 5. Concrete changes in this port + +- v1 hooks → v2 `Plugin.define({ id: "auto-resume.v2", setup })` + + background `ctx.event.subscribe()` pump; cleanup returned from `setup` + clears the watchdog interval. +- `client.session.prompt({path, body})` → `ctx.session.prompt({ sessionID, + text })` (a nested-shape fallback is kept while the beta API settles). +- `session.messages()` forensics → live accumulation of assistant text from + `session.text.delta` / `session.reasoning.delta` events (message history + is not reachable from the v2 plugin context). +- `ctx.client.app.log(...)` → prefixed console logging. +- Status polling demoted: session state comes primarily from lifecycle + events; a low-frequency interval cross-checks active sessions for silence + only. +- New guards made possible/necessary by v2 semantics: + - `permission.asked` / `permission.replied` hold off recovery while a + permission dialog is open. + - Subagent-aware waiting: a parent silent right after dispatching a task + tool with other sessions running is left alone. + - Failure-triggered recoveries arm a flag so the failure's own idle + transition doesn't cancel the scheduled retry prompt, while a healthy + completion still cancels stale ones. + +## Feature parity retained + +Stall watchdog · failure recovery with exponential backoff → interrupt+resume +escalation · tool-call-as-text detection · "ready to continue"/action-intent +nudges · done-claim verification · hallucination-loop guard (N continues in +window → abort+resume) · tool-loop pattern detection · user-interrupt +respected. + +## Options + +Unchanged names/defaults from v1: `chunkTimeoutMs` (45000), `gracePeriodMs` +(3000), `checkIntervalMs` (5000), `maxRetries` (3), `baseBackoffMs` (1000), +`maxBackoffMs` (8000), `loopMaxContinues` (3), `loopWindowMs` (600000), +plus `maxRecoveryRetries`, `continuePrompt`, `debug`. + +```jsonc +{ + "plugins": [ + { + "package": "./plugins/auto-resume-v2.ts", + "options": { "chunkTimeoutMs": 45000, "maxRetries": 3 } + } + ] +} +``` + +Disable by id without touching other plugins: add `"-auto-resume.v2"`. + +## Testing + +- `tsc --strict --noEmit` clean against `@opencode-ai/plugin@0.0.0-next-17403`. +- Mocked-context runtime suite (bun): export shape · stall watchdog → prompt · + ready-to-continue nudge · tool-call-as-text recovery · loop guard → interrupt · + permission-hold blocks recovery · execution-failure recovery · stale recovery + does not disturb healthy idle sessions · user interrupt honored · cleanup + quiesces timers — **12/12 passing**. diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..57ee468 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,931 @@ +/** + * opencode-auto-resume — adapted for OpenCode v2 plugin API. + * + * Port of https://github.com/Mte90/opencode-auto-resume (v1 hooks API) to the + * v2 promise-plugin API (`Plugin.define` + `ctx.event.subscribe()`). + * + * What changed vs. v1: + * - Default export is `{ id, setup }` via `Plugin.define`; setup returns a cleanup fn. + * - Events come from `ctx.event.subscribe()` (AsyncIterable) with flat payloads + * (`{ type, data }`) instead of the v1 `event` hook (`{ type, properties }`). + * - SDK calls are flattened: `ctx.session.prompt({ sessionID, text })`, + * `ctx.session.interrupt({ sessionID })`, `ctx.session.active()`. + * - There is no message-history access from the v2 plugin context, so assistant + * text is reconstructed from `session.text.delta` events instead of + * `session.messages()` polling. + * - No `ctx.app.log` in v2 — logs go to the console (captured by opencode logs). + * + * Detection/recovery features ported: + * - Stalled stream watchdog (busy session with no events for chunkTimeoutMs) + * - Execution/step failure recovery (`session.execution.failed`, `session.step.failed`) + * - Provider retry awareness (`session.retry.scheduled`) + * - Tool-call-printed-as-text detection + targeted recovery prompt + * - "Ready to continue" / stalled-intent ("...:") nudges + * - "Done" claim without work verification + * - Hallucination loop guard (too many auto-continues → interrupt + resume) + * - Subagent-awareness: does not recover a parent blocked on a running subagent + * - Permission-awareness: never recovers while a permission dialog is open + * + * Install: drop this file in ~/.config/opencode/plugins/ (auto-loaded), or add: + * { "plugins": [{ "package": "./plugins/auto-resume-v2.ts", "options": { ... } }] } + */ + +import { Plugin } from "@opencode-ai/plugin" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ToolCallRecord { + toolName: string + at: number +} + +/** Minimal structural view of a V2Event (avoids depending on client internals). */ +interface V2Event { + type: string + created: number + data?: Record +} + +interface SessionWatch { + createdAt: number + lastActivityAt: number + status: "busy" | "idle" | "unknown" + userCancelled: boolean + resumeAttempts: number + lastRetryAt: number + gaveUp: boolean + aborting: boolean + recovering: boolean + + // Assistant text accumulation (v2 replacement for session.messages()) + textParts: Map // assistantMessageID -> accumulated text + lastAssistantText: string + lastAssistantMessageID: string | null + + // Recovery budgets + toolTextAttempts: number + continueTimestamps: number[] + doneClaimAttempts: number + intentNudgeAttempts: number + /** Set when a failure-triggered recovery is pending, so the delayed prompt isn't cancelled by the failure's own idle transition. */ + pendingRecoveryArmed: boolean + + // Guards + permissionPending: boolean + waitingOnSubagent: boolean + lastWasTaskTool: boolean + idleSince: number | null + + // Tool-loop tracking + recentToolCalls: ToolCallRecord[] + + // Agent/model from last step (informational logging only; sessions are stateful in v2) + agent?: string + model?: string +} + +export interface AutoResumeOptions { + chunkTimeoutMs?: number + gracePeriodMs?: number + checkIntervalMs?: number + maxRetries?: number + baseBackoffMs?: number + maxBackoffMs?: number + loopMaxContinues?: number + loopWindowMs?: number + maxRecoveryRetries?: number + continuePrompt?: string + toolTextRecoveryPrompt?: string + doneWithoutWorkPrompt?: string + actionIntentPrompt?: string + debug?: boolean +} + +// --------------------------------------------------------------------------- +// Constants & defaults +// --------------------------------------------------------------------------- + +const DEFAULT_CHUNK_TIMEOUT_MS = 45_000 +const DEFAULT_CHECK_INTERVAL_MS = 5_000 +const DEFAULT_GRACE_PERIOD_MS = 3_000 +const DEFAULT_MAX_RETRIES = 3 +const DEFAULT_BASE_BACKOFF_MS = 1_000 +const DEFAULT_MAX_BACKOFF_MS = 8_000 +const DEFAULT_LOOP_MAX_CONTINUES = 3 +const DEFAULT_LOOP_WINDOW_MS = 10 * 60_000 +const DEFAULT_MAX_RECOVERY_RETRIES = 2 +const DEFAULT_DEBUG = false + +const MAX_IDLE_SESSIONS = 50 +const IDLE_CLEANUP_MS = 10 * 60_000 +const TEXT_BUFFER_TRIM_LEN = 20_000 + +const CONTINUE_PROMPT = "continue" + +const TOOL_TEXT_RECOVERY_PROMPT = + "Your last message contained a raw tool call printed as text instead of being executed. " + + "Please use the proper tool calling mechanism to execute it." + +const DONE_WITHOUT_WORK_PROMPT = + "I need you to verify more carefully that you have actually completed all the required tasks. " + + "Your response indicated you're done, but no work was detected. Please check your todo list " + + "and complete any remaining work." + +const TOOL_LOOP_RECOVERY_PROMPT = + "I notice you've been calling the same tool multiple times in a row without making progress. " + + "Please step back and reassess your approach. Consider: " + + "1) Are you stuck in a loop? 2) Do you need different information first? " + + "3) Should you try a different tool or break the task into smaller steps? " + + "Take a moment to think about what's blocking you and propose a different strategy." + +const TASK_TOOL_HINTS = ["task", "agent", "subagent", "dispatch"] + +// --------------------------------------------------------------------------- +// Pattern lists (ported verbatim from upstream where pure) +// --------------------------------------------------------------------------- + +const TOOL_TEXT_PATTERNS = [ + //i, + /<\/function>/i, + //i, + /<\/parameter>/i, + /]/i, + /<\/tool_call>/i, + /]*)?\s*(?:\/>|>)/i, + /{"type":\s*"function"/i, + /{"name":\s*"[a-zA-Z_]/i, + /\{\s*"type"\s*:?$/im, + /\{\s*"name"\s*:?$/im, +] + +const TRUNCATED_XML_PATTERNS = [ + { open: /]*>/i, close: /<\/function>/i }, + { open: /]*>/i, close: /<\/parameter>/i }, + { open: /]*>/i, close: /<\/tool_call>/i }, + { open: /\{\s*"type"\s*:/i, close: /}/ }, + { open: /\{\s*"name"\s*:/i, close: /}/ }, +] + +const READY_TO_CONTINUE_PATTERNS = [ + /ready to continue with task/i, + /continuing with task/i, + /continue with task/i, + /proceeding with task/i, + /ready to proceed with task/i, + /will continue with task/i, + /moving on to task/i, +] + +const DONE_CLAIM_PATTERNS = [ + /^task\s+done[.!]*$/im, + /^done[.!]*$/im, + /^all\s+done[.!]*$/im, + /^finished[.!]*$/im, + /^complete[.!]*$/im, + /^task\s+complete[.!]*$/im, + /^task\s+completed[.!]*$/im, + /^all\s+tasks?\s+complete[.!]*$/im, + /^all\s+tasks?\s+completed[.!]*$/im, + /^(?:i['’]?m\s+)?done\s+with\s+task/im, + /\bdone\s+with\s+(?:the\s+)?(?:task|work|implementation)/im, + /\bfinished\s+(?:the\s+)?(?:task|work|implementation)/im, + /\b(?:all|everything)\s+(?:is\s+)?(?:complete|done|finished)/im, + /\bnothing\s+(?:else\s+)?(?:left|remaining|to do)/im, +] + +// Error signatures that indicate a transient streaming/provider failure worth retrying +const STREAMING_FAILURE_MESSAGE_PATTERNS = [ + "stream.*fail", + "stream.*timeout", + "connection.*reset", + "connection.*closed", + "connection.*error", + "socket.*hang", + "econnreset", + "etimedout", + "rate.?limit", + "overloaded", + "server error", + "internal error", +] + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +function stripCodeBlocks(text: string): string { + return text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "") +} + +function containsToolCallAsText(text: string): boolean { + if (text.length <= 10) return false + const stripped = stripCodeBlocks(text) + if (TOOL_TEXT_PATTERNS.some((pat) => pat.test(stripped))) return true + for (const { open, close } of TRUNCATED_XML_PATTERNS) { + if (open.test(stripped) && !close.test(stripped)) return true + } + return false +} + +function containsReadyToContinuePattern(text: string): boolean { + const lines = text.split("\n") + const lastLines = lines.slice(-3).join("\n") + return READY_TO_CONTINUE_PATTERNS.some((pat) => pat.test(lastLines)) +} + +function containsDoneClaimPattern(text: string): boolean { + const lines = text.split("\n") + const lastLines = lines.slice(-5).join("\n") + return DONE_CLAIM_PATTERNS.some((pat) => pat.test(lastLines)) +} + +/** Model ends with ":" announcing intent without executing. */ +function containsActionIntent(text: string): boolean { + if (text.length <= 15) return false + const cleaned = text.replace(/<[a-zA-Z/?][^>]*>/g, "").trim() + const lines = cleaned.split("\n") + let lastLine = "" + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim().length > 0) { + lastLine = lines[i].trim() + break + } + } + return lastLine.endsWith(":") && lastLine.length > 5 && lastLine.length < 500 +} + +function backoffMs(attempt: number, base: number, max: number): number { + return Math.min(base * Math.pow(2, attempt - 1), max) +} + +function isStreamingFailure(message: string): boolean { + const lower = message.toLowerCase() + if (!lower) return false + return STREAMING_FAILURE_MESSAGE_PATTERNS.some((pattern) => { + try { + return new RegExp(pattern, "i").test(lower) + } catch { + return lower.includes(pattern.toLowerCase()) + } + }) +} + +/** Repeating tool-call patterns: A-A-A or A-B-A-B-A-B etc. */ +function detectPatternLoop(recentTools: string[]): boolean { + if (recentTools.length < 6) return false + for (const patternLen of [1, 2, 3]) { + if (recentTools.length < patternLen * 3) continue + const pattern = recentTools.slice(-patternLen) + let matches = 0 + for (let i = recentTools.length - patternLen * 2; i >= 0; i -= patternLen) { + const slice = recentTools.slice(i, i + patternLen) + if (slice.length !== patternLen) break + if (!slice.every((t, idx) => t === pattern[idx])) break + matches++ + } + if (matches >= 2) return true + } + return false +} + +function trackToolCall(w: SessionWatch, toolName: string): boolean { + const now = Date.now() + w.recentToolCalls = w.recentToolCalls.filter((c) => now - c.at < 120_000) + w.recentToolCalls.push({ toolName, at: now }) + const recentTools = w.recentToolCalls.slice(-12).map((c) => c.toolName) + if (recentTools.length < 6) return false + const lastTool = recentTools[recentTools.length - 1] + const consecutiveSame = recentTools.slice(-4).filter((t) => t === lastTool).length + if (consecutiveSame >= 4) return true + return detectPatternLoop(recentTools) +} + +function short(sid: string): string { + return sid.length > 12 ? `…${sid.slice(-8)}` : sid +} + +function sidOf(ev: V2Event): string | undefined { + const sid = ev.data?.sessionID + return typeof sid === "string" ? sid : undefined +} + +function isTaskToolCall(ev: V2Event): boolean { + const tool = ev.data?.tool ?? ev.data?.toolName + if (typeof tool === "string") { + const lower = tool.toLowerCase() + if (TASK_TOOL_HINTS.some((h) => lower.includes(h))) return true + } + // Also inspect input for agent-ish payloads + const desc = ev.data?.input?.description ?? ev.data?.input?.subagent_type ?? ev.data?.input?.agent + return typeof desc === "string" +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +export default Plugin.define({ + id: "auto-resume.v2", + + setup: async (ctx) => { + const opts = (ctx.options ?? {}) as AutoResumeOptions + + const chunkTimeoutMs = opts.chunkTimeoutMs ?? DEFAULT_CHUNK_TIMEOUT_MS + const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS + const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS + const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES + const baseBackoff = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS + const maxBackoff = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS + const loopMaxContinues = opts.loopMaxContinues ?? DEFAULT_LOOP_MAX_CONTINUES + const loopWindowMs = opts.loopWindowMs ?? DEFAULT_LOOP_WINDOW_MS + const maxRecoveryRetries = opts.maxRecoveryRetries ?? DEFAULT_MAX_RECOVERY_RETRIES + const debug = opts.debug ?? DEFAULT_DEBUG + + const dbg = (...args: unknown[]) => { + if (debug) console.log("[auto-resume:debug]", ...args) + } + + function log(level: "info" | "warn" | "error", msg: string) { + const line = `[auto-resume] ${msg}` + if (level === "error") console.error(line) + else if (level === "warn") console.warn(line) + else console.log(line) + } + + // --------------------------------------------------------------------- + // State + // --------------------------------------------------------------------- + + const sessions = new Map() + + function ensureWatch(sid: string): SessionWatch { + let w = sessions.get(sid) + if (!w) { + w = { + createdAt: Date.now(), + lastActivityAt: Date.now(), + status: "unknown", + userCancelled: false, + resumeAttempts: 0, + lastRetryAt: 0, + gaveUp: false, + aborting: false, + recovering: false, + textParts: new Map(), + lastAssistantText: "", + lastAssistantMessageID: null, + toolTextAttempts: 0, + continueTimestamps: [], + doneClaimAttempts: 0, + intentNudgeAttempts: 0, + pendingRecoveryArmed: false, + permissionPending: false, + waitingOnSubagent: false, + lastWasTaskTool: false, + idleSince: null, + recentToolCalls: [], + } + sessions.set(sid, w) + } + return w + } + + function touch(sid: string) { + const w = ensureWatch(sid) + w.lastActivityAt = Date.now() + } + + function markBusy(sid: string) { + const w = ensureWatch(sid) + if (w.status !== "busy") { + dbg(`${short(sid)} idle/unknown -> busy`) + // Fresh busy cycle: reset per-turn budgets. + // NOTE: continueTimestamps is intentionally preserved — the + // hallucination-loop detector counts across busy cycles by design. + w.resumeAttempts = 0 + w.toolTextAttempts = 0 + w.doneClaimAttempts = 0 + w.intentNudgeAttempts = 0 + w.gaveUp = false + w.recentToolCalls = [] + w.textParts.clear() + w.lastAssistantText = "" + w.waitingOnSubagent = false + } + w.status = "busy" + w.idleSince = null + w.lastActivityAt = Date.now() + } + + function markIdle(sid: string) { + const w = ensureWatch(sid) + if (w.status !== "idle") { + dbg(`${short(sid)} ${w.status} -> idle`) + w.status = "idle" + w.idleSince = Date.now() + } + w.permissionPending = false + } + + function recordContinue(sid: string) { + const w = sessions.get(sid) + if (!w) return + const now = Date.now() + w.continueTimestamps.push(now) + const cutoff = now - loopWindowMs + while (w.continueTimestamps.length > 0 && w.continueTimestamps[0] < cutoff) { + w.continueTimestamps.shift() + } + } + + function continuesInWindow(w: SessionWatch): number { + const cutoff = Date.now() - loopWindowMs + while (w.continueTimestamps.length > 0 && w.continueTimestamps[0] < cutoff) { + w.continueTimestamps.shift() + } + return w.continueTimestamps.length + } + + function cleanupIdleSessions() { + const now = Date.now() + const busy = new Set() + for (const [sid, w] of sessions) { + if (w.status === "busy") busy.add(sid) + } + let idleCount = 0 + const toDelete: string[] = [] + for (const [sid, w] of sessions) { + if (w.status !== "busy") { + idleCount++ + if (w.idleSince && now - w.idleSince > IDLE_CLEANUP_MS) toDelete.push(sid) + } + } + if (idleCount > MAX_IDLE_SESSIONS) { + const entries: Array<{ sid: string; since: number }> = [] + for (const [sid, w] of sessions) { + if (w.status !== "busy" && w.idleSince) entries.push({ sid, since: w.idleSince }) + } + entries.sort((a, b) => a.since - b.since) + const excess = idleCount - MAX_IDLE_SESSIONS + for (let i = 0; i < excess && i < entries.length; i++) { + if (!toDelete.includes(entries[i].sid)) toDelete.push(entries[i].sid) + } + } + for (const sid of toDelete) sessions.delete(sid) + if (toDelete.length > 0) dbg(`cleaned ${toDelete.length} idle sessions, total=${sessions.size}`) + } + + /** + * Accumulate assistant text from deltas. This replaces v1's + * `session.messages()` polling, which no longer exists on the v2 ctx. + */ + function appendText(sid: string, messageID: string | undefined, delta: string) { + const w = ensureWatch(sid) + const mid = messageID ?? "_anon" + w.textParts.set(mid, (w.textParts.get(mid) ?? "") + delta) + w.lastAssistantMessageID = mid + w.lastAssistantText = w.textParts.get(mid) ?? "" + // Keep memory bounded: keep only the two most recent messages' text + if (w.textParts.size > 2) { + const oldest = w.textParts.keys().next().value + if (oldest !== undefined && oldest !== mid) w.textParts.delete(oldest) + } + if (w.lastAssistantText.length > TEXT_BUFFER_TRIM_LEN) { + w.textParts.set(mid, w.lastAssistantText.slice(-TEXT_BUFFER_TRIM_LEN)) + w.lastAssistantText = w.textParts.get(mid) ?? "" + } + } + + // --------------------------------------------------------------------- + // Recovery actions + // --------------------------------------------------------------------- + + async function sendPrompt(sid: string, text: string): Promise { + try { + await ctx.session.prompt({ sessionID: sid, text }) + return true + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + log("warn", `${short(sid)} prompt failed: ${msg}`) + // One flattened-shape fallback for beta drift safety + try { + await (ctx.session as any).prompt({ path: { id: sid }, body: { parts: [{ type: "text", text }] } }) + return true + } catch { + log("error", `${short(sid)} prompt failed twice: ${msg}`) + return false + } + } + } + + async function tryAbortAndResume(sid: string, w: SessionWatch): Promise { + if (w.aborting) return false + w.aborting = true + log("warn", `${short(sid)} escalating: interrupt + fresh continue`) + try { + await ctx.session.interrupt({ sessionID: sid }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + log("warn", `${short(sid)} interrupt failed: ${msg}`) + } + // Give the runtime a beat to settle the interrupted turn + await new Promise((r) => setTimeout(r, 2_000)) + w.aborting = false + w.resumeAttempts = 0 + const ok = await sendPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT) + if (ok) { + recordContinue(sid) + w.lastRetryAt = Date.now() + log("info", `${short(sid)} resumed after abort`) + } + return ok + } + + /** + * Core recovery ladder for a stuck/failed session. + * plain continue with backoff -> more attempts -> abort+resume escalation. + */ + async function recover(sid: string, reason: string) { + const w = ensureWatch(sid) + if (w.recovering || w.aborting || w.gaveUp || w.userCancelled || w.permissionPending) return + // Record intent first, then evaluate the loop guard, so the Nth + // continue within the window is the one that escalates. + recordContinue(sid) + if (continuesInWindow(w) >= loopMaxContinues) { + log("warn", `${short(sid)} hallucination loop (${loopMaxContinues} continues/${loopWindowMs / 1000}s) — abort+resume`) + await tryAbortAndResume(sid, w) + w.continueTimestamps = [] + return + } + if (w.resumeAttempts >= maxRetries) { + log("warn", `${short(sid)} giving up after ${maxRetries} attempts (${reason})`) + w.gaveUp = true + return + } + w.recovering = true + w.resumeAttempts++ + const delay = backoffMs(w.resumeAttempts, baseBackoff, maxBackoff) + const attempt = w.resumeAttempts + log( + "info", + `${short(sid)} stall detected (${reason}) — resume attempt ${attempt}/${maxRetries} in ${delay}ms`, + ) + setTimeout(async () => { + try { + // Skip only if the session genuinely turned healthy again + // (a normal completion clears pendingRecoveryArmed) or the + // user took over. + if (w.userCancelled || w.gaveUp) return + if (w.status === "idle" && !w.pendingRecoveryArmed) return // recovered by itself meanwhile + const ok = await sendPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT) + w.pendingRecoveryArmed = false + if (ok) { + w.lastRetryAt = Date.now() + touch(sid) + markBusy(sid) + } else if (attempt >= maxRetries) { + await tryAbortAndResume(sid, w) + } + } finally { + w.recovering = false + } + }, delay) + } + + /** Targeted recovery prompts (tool-as-text, done-claims, intent nudges). */ + async function targetedRecovery(sid: string, kind: string, prompt: string, budgetKey: "toolTextAttempts" | "doneClaimAttempts" | "intentNudgeAttempts") { + const w = ensureWatch(sid) + if (w.recovering || w.userCancelled || w.permissionPending) return + recordContinue(sid) + if (continuesInWindow(w) >= loopMaxContinues) { + log("warn", `${short(sid)} loop guard before ${kind} nudge — abort+resume`) + await tryAbortAndResume(sid, w) + w.continueTimestamps = [] + return + } + if (w[budgetKey] >= maxRetries) { + dbg(`${short(sid)} ${kind} budget exhausted`) + return + } + w[budgetKey]++ + log("info", `${short(sid)} ${kind} detected — sending targeted prompt (${w[budgetKey]}/${maxRetries})`) + w.recovering = true + const ok = await sendPrompt(sid, prompt) + w.recovering = false + if (ok) { + recordContinue(sid) + touch(sid) + markBusy(sid) + } + } + + // --------------------------------------------------------------------- + // Idle-time forensics (runs once when a turn finishes) + // --------------------------------------------------------------------- + + async function inspectOnIdle(sid: string) { + const w = ensureWatch(sid) + const text = w.lastAssistantText + if (!text) return + + if (containsToolCallAsText(text)) { + await targetedRecovery(sid, "tool-call-as-text", opts.toolTextRecoveryPrompt ?? TOOL_TEXT_RECOVERY_PROMPT, "toolTextAttempts") + return + } + if (containsReadyToContinuePattern(text)) { + await targetedRecovery(sid, "ready-to-continue", opts.continuePrompt ?? CONTINUE_PROMPT, "intentNudgeAttempts") + return + } + if (containsActionIntent(text)) { + await targetedRecovery(sid, "action-intent", opts.actionIntentPrompt ?? opts.continuePrompt ?? CONTINUE_PROMPT, "intentNudgeAttempts") + return + } + if (containsDoneClaimPattern(text) && w.doneClaimAttempts < 1) { + // Single verification nudge for suspiciously terse completions + const trimmed = text.trim() + if (trimmed.length < 400) { + await targetedRecovery(sid, "done-claim-no-details", opts.doneWithoutWorkPrompt ?? DONE_WITHOUT_WORK_PROMPT, "doneClaimAttempts") + } + } + } + + // --------------------------------------------------------------------- + // Watchdog timer + // --------------------------------------------------------------------- + + /** + * `session.active()` exists on the v2 client but is not part of the + * plugin SessionDomain pick — access defensively; empty map if missing. + */ + async function getActiveSessions(): Promise { + try { + const fn = (ctx.session as any).active + if (typeof fn !== "function") return [] + const active = await fn.call(ctx.session) + return Object.keys(active ?? {}).filter((k) => typeof k === "string") + } catch { + return [] + } + } + + async function checkActiveSessions() { + const now = Date.now() + + // Cross-check our busy set against server truth (also catches sessions + // we never saw an execution.started for, e.g. after plugin reload). + const activeIDs = await getActiveSessions() + for (const sid of activeIDs) { + const w = ensureWatch(sid) + if (w.status !== "busy") markBusy(sid) + } + + for (const [sid, w] of sessions) { + if (w.status !== "busy" || w.userCancelled) continue + const silence = now - w.lastActivityAt + if (silence < chunkTimeoutMs + gracePeriodMs) continue + if (w.permissionPending) { + dbg(`${short(sid)} silent but permission pending — skipping`) + continue + } + if (w.waitingOnSubagent) { + dbg(`${short(sid)} silent but waiting on subagent — skipping`) + continue + } + // If another session is actively running and this one went silent + // right after dispatching a task tool, treat it as a parent wait. + if (w.lastWasTaskTool) { + const others = (await getActiveSessions()).filter((s) => s !== sid) + if (others.length > 0) { + dbg(`${short(sid)} silent after task tool with ${others.length} active child(ren) — waiting`) + continue + } + } + await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`) + } + cleanupIdleSessions() + } + + const watchdog = setInterval(() => { + checkActiveSessions().catch((e) => + log("error", `watchdog failed: ${e instanceof Error ? e.message : String(e)}`), + ) + }, checkIntervalMs) + + // --------------------------------------------------------------------- + // Event stream + // --------------------------------------------------------------------- + + function handleEvent(ev: V2Event) { + switch (ev.type) { + // --- lifecycle ------------------------------------------------- + case "session.execution.started": { + const sid = sidOf(ev) + if (!sid) return + markBusy(sid) + return + } + case "session.execution.succeeded": + case "session.idle": { + const sid = sidOf(ev) + if (!sid) return + markIdle(sid) + ensureWatch(sid).pendingRecoveryArmed = false + void inspectOnIdle(sid) + return + } + case "session.execution.interrupted": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + // Only user interrupts should suppress us; ours reset flags themselves. + w.userCancelled = !w.aborting + w.recovering = false + markIdle(sid) + return + } + case "session.deleted": + case "session.reverted": { + const sid = sidOf(ev) + if (!sid) return + sessions.delete(sid) + return + } + + // --- activity -------------------------------------------------- + case "session.step.started": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + w.agent = typeof ev.data?.agent === "string" ? ev.data.agent : w.agent + w.model = + ev.data?.model && typeof ev.data.model === "object" + ? `${ev.data.model.providerID ?? ev.data.model.provider ?? "?"}/${ev.data.model.modelID ?? ev.data.model.id ?? "?"}` + : w.model + w.lastWasTaskTool = false + markBusy(sid) + return + } + case "session.step.ended": { + const sid = sidOf(ev) + if (!sid) return + touch(sid) + return + } + case "session.text.delta": + case "session.reasoning.delta": { + const sid = sidOf(ev) + if (!sid) return + appendText(sid, ev.data?.assistantMessageID, typeof ev.data?.delta === "string" ? ev.data.delta : "") + touch(sid) + return + } + case "session.text.ended": + case "session.reasoning.ended": { + const sid = sidOf(ev) + if (!sid) return + touch(sid) + return + } + + // --- tools ------------------------------------------------------ + case "session.tool.called": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + const name = typeof ev.data?.tool === "string" ? ev.data.tool : (ev.data?.id as string | undefined) ?? "tool" + w.lastWasTaskTool = isTaskToolCall(ev) + if (trackToolCall(w, name)) { + void targetedRecovery(sid, "tool-loop", TOOL_LOOP_RECOVERY_PROMPT, "intentNudgeAttempts").catch(() => {}) + } + touch(sid) + return + } + case "session.tool.progress": + case "session.shell.started": + case "session.shell.ended": { + const sid = sidOf(ev) + if (!sid) return + touch(sid) + return + } + case "session.tool.success": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + w.lastWasTaskTool = false + touch(sid) + return + } + case "session.tool.failed": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + w.lastWasTaskTool = false + touch(sid) + return + } + + // --- permissions ------------------------------------------------- + case "permission.asked": { + const sid = sidOf(ev) + if (!sid) return + ensureWatch(sid).permissionPending = true + return + } + case "permission.replied": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + w.permissionPending = false + touch(sid) + return + } + + // --- failures ---------------------------------------------------- + case "session.retry.scheduled": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + touch(sid) // provider-level retry is progress of its own kind + const errType = String(ev.data?.error?.type ?? "") + const errMsg = String(ev.data?.error?.message ?? "") + log("info", `${short(sid)} provider retry #${ev.data?.attempt ?? "?"}: ${errType || errMsg}`) + if (!isStreamingFailure(errMsg) && errType !== "retryable") return + // Let the provider retries play out first; only intervene if it stays quiet + if (nowSilenceTooLong(w)) { + w.pendingRecoveryArmed = true + void recover(sid, "streaming failure with scheduled retry") + } + return + } + case "session.step.failed": + case "session.execution.failed": { + const sid = sidOf(ev) + if (!sid) return + const w = ensureWatch(sid) + const errMsg = String(ev.data?.error?.message ?? "") + const errType = String(ev.data?.error?.type ?? "") + markIdle(sid) + w.pendingRecoveryArmed = true // our delayed recovery must survive this idle transition + if (errMsg.includes("interrupted by user") || errType.includes("cancel")) { + dbg(`${short(sid)} failure was user-initiated — not recovering`) + w.pendingRecoveryArmed = false + return + } + log("warn", `${short(sid)} ${ev.type}: ${errType || "error"} ${errMsg.slice(0, 160)}`) + void recover(sid, `${ev.type}${isStreamingFailure(errMsg) ? " (streaming)" : ""}`) + return + } + + default: + return + } + } + + function nowSilenceTooLong(w: SessionWatch): boolean { + return Date.now() - w.lastActivityAt > chunkTimeoutMs + gracePeriodMs + } + + // Subscribe and pump events in the background (setup must not block). + let running = true + const pump = async () => { + try { + const stream = ctx.event.subscribe() + for await (const raw of stream) { + if (!running) return + const ev = raw as unknown as V2Event + if (!ev || typeof ev.type !== "string") continue + try { + handleEvent(ev) + } catch (e) { + dbg("handler error:", e instanceof Error ? e.message : String(e)) + } + } + } catch (e) { + if (running) log("error", `event stream ended: ${e instanceof Error ? e.message : String(e)}`) + } + } + void pump() + + log( + "info", + `ready (opencode v2). timeout=${chunkTimeoutMs}ms interval=${checkIntervalMs}ms retries=${maxRetries} loop=${loopMaxContinues}/${loopWindowMs / 1000}s`, + ) + + // Cleanup: stop timers and the pump; OpenCode awaits this on disable/reload/shutdown. + return () => { + running = false + clearInterval(watchdog) + sessions.clear() + log("info", "stopped") + } + }, +}) From 5e04aded99bfa92f7f8466e2b60be635c9344774 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Wed, 26 Aug 2026 11:25:34 +0200 Subject: [PATCH 2/5] feat(plugin): show visible notification when recovering from stall/failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace session.prompt() with session.synthetic() which injects a visible message into the session timeline (shown in TUI) so the user knows the plugin intervened. The synthetic message also resumes the session when resume=true, replacing the separate prompt call. Notification text per recovery path: - "auto-resume: stalled — retrying" (watchdog stall) - "auto-resume: recovering: " (tool-text, ready-to-continue, etc.) - "auto-resume: abort+resume escalation" (loop guard / max retries) Falls back to session.prompt() if synthetic is not available (beta compat). 12/12 typecheck + smoke tests pass. --- src/v2/index.ts | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index 57ee468..6f417e5 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -508,20 +508,43 @@ export default Plugin.define({ // Recovery actions // --------------------------------------------------------------------- - async function sendPrompt(sid: string, text: string): Promise { + /** + * Show a visible notification in the session timeline and optionally + * resume it. Uses `session.synthetic()` which is exposed on the v2 + * promise-plugin context — the synthetic message appears in the TUI + * so the user knows the plugin intervened. + * + * When `resume` is true the synthetic also acts as a user turn that + * kicks the session back to life, replacing the separate `prompt()`. + */ + async function notifyAndPrompt(sid: string, text: string, notification: string, resume = true): Promise { try { - await ctx.session.prompt({ sessionID: sid, text }) + await ctx.session.synthetic({ + sessionID: sid, + text, + description: `auto-resume: ${notification}`, + resume, + }) return true } catch (err) { const msg = err instanceof Error ? err.message : String(err) - log("warn", `${short(sid)} prompt failed: ${msg}`) - // One flattened-shape fallback for beta drift safety + log("warn", `${short(sid)} synthetic failed: ${msg}`) + // Flat-shape fallback for beta drift try { - await (ctx.session as any).prompt({ path: { id: sid }, body: { parts: [{ type: "text", text }] } }) + await (ctx.session as any).synthetic({ + path: { id: sid }, + body: { text, description: `auto-resume: ${notification}`, resume }, + }) return true } catch { - log("error", `${short(sid)} prompt failed twice: ${msg}`) - return false + // Last resort: bare prompt + try { + await ctx.session.prompt({ sessionID: sid, text }) + return true + } catch { + log("error", `${short(sid)} all recovery attempts failed: ${msg}`) + return false + } } } } @@ -540,7 +563,7 @@ export default Plugin.define({ await new Promise((r) => setTimeout(r, 2_000)) w.aborting = false w.resumeAttempts = 0 - const ok = await sendPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT) + const ok = await notifyAndPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT, "abort+resume escalation") if (ok) { recordContinue(sid) w.lastRetryAt = Date.now() @@ -585,7 +608,7 @@ export default Plugin.define({ // user took over. if (w.userCancelled || w.gaveUp) return if (w.status === "idle" && !w.pendingRecoveryArmed) return // recovered by itself meanwhile - const ok = await sendPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT) + const ok = await notifyAndPrompt(sid, opts.continuePrompt ?? CONTINUE_PROMPT, "stalled — retrying") w.pendingRecoveryArmed = false if (ok) { w.lastRetryAt = Date.now() @@ -618,7 +641,7 @@ export default Plugin.define({ w[budgetKey]++ log("info", `${short(sid)} ${kind} detected — sending targeted prompt (${w[budgetKey]}/${maxRetries})`) w.recovering = true - const ok = await sendPrompt(sid, prompt) + const ok = await notifyAndPrompt(sid, prompt, "recovering: " + kind) w.recovering = false if (ok) { recordContinue(sid) From ad92b3984767e7bbd1da722304af8788c298b7b3 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Thu, 17 Sep 2026 15:03:54 +0200 Subject: [PATCH 3/5] feat(v2): target stable @opencode/plugin 2.0.5 API - import from @opencode/plugin (stable package; @opencode-ai/plugin was beta-only) - read authoritative assistant text from ctx.session.context() at idle time, keeping the delta accumulation for liveness - pass an AbortSignal to ctx.event.subscribe() and abort it on cleanup - honour session.execution.interrupted data.reason (only "user" disables recovery) - drop the beta-era nested synthetic({ path, body }) fallback - document session.reverted as v1 legacy (v2 emits session.revert.*) --- src/v2/index.ts | 86 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index 6f417e5..8f4fe0e 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -10,10 +10,12 @@ * (`{ type, data }`) instead of the v1 `event` hook (`{ type, properties }`). * - SDK calls are flattened: `ctx.session.prompt({ sessionID, text })`, * `ctx.session.interrupt({ sessionID })`, `ctx.session.active()`. - * - There is no message-history access from the v2 plugin context, so assistant - * text is reconstructed from `session.text.delta` events instead of - * `session.messages()` polling. + * - Assistant text is accumulated from `session.text.delta` events for liveness; + * `ctx.session.context()` (stable v2) supplies the authoritative final + * assistant text at idle time — replacing v1's `session.messages()` polling. * - No `ctx.app.log` in v2 — logs go to the console (captured by opencode logs). + * - Targets the stable v2 API (`@opencode/plugin`). Event names are unchanged + * from the beta port; `session.execution.interrupted` now carries a `reason`. * * Detection/recovery features ported: * - Stalled stream watchdog (busy session with no events for chunkTimeoutMs) @@ -30,7 +32,7 @@ * { "plugins": [{ "package": "./plugins/auto-resume-v2.ts", "options": { ... } }] } */ -import { Plugin } from "@opencode-ai/plugin" +import { Plugin } from "@opencode/plugin" // --------------------------------------------------------------------------- // Types @@ -529,22 +531,14 @@ export default Plugin.define({ } catch (err) { const msg = err instanceof Error ? err.message : String(err) log("warn", `${short(sid)} synthetic failed: ${msg}`) - // Flat-shape fallback for beta drift + // Last resort: a plain prompt still resumes the session (just + // without the visible synthetic notification in the TUI). try { - await (ctx.session as any).synthetic({ - path: { id: sid }, - body: { text, description: `auto-resume: ${notification}`, resume }, - }) + await ctx.session.prompt({ sessionID: sid, text }) return true } catch { - // Last resort: bare prompt - try { - await ctx.session.prompt({ sessionID: sid, text }) - return true - } catch { - log("error", `${short(sid)} all recovery attempts failed: ${msg}`) - return false - } + log("error", `${short(sid)} all recovery attempts failed: ${msg}`) + return false } } } @@ -654,9 +648,40 @@ export default Plugin.define({ // Idle-time forensics (runs once when a turn finishes) // --------------------------------------------------------------------- + /** + * Read the last assistant message's text from the session message history + * (`ctx.session.context()`, stable v2 API). Returns "" when unavailable. + * Guarded so a failure in this forensic path never breaks the watchdog. + */ + async function lastAssistantTextFromContext(sid: string): Promise { + try { + const messages = await ctx.session.context({ sessionID: sid }) + if (!Array.isArray(messages)) return "" + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] as { + type?: string + content?: Array<{ type?: string; text?: string }> + } + if (!msg || msg.type !== "assistant" || !Array.isArray(msg.content)) continue + const text = msg.content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("") + if (text) return text + } + return "" + } catch (e) { + dbg("session.context() fallback failed:", e instanceof Error ? e.message : String(e)) + return "" + } + } + async function inspectOnIdle(sid: string) { const w = ensureWatch(sid) - const text = w.lastAssistantText + // Prefer the live delta buffer; fall back to the authoritative message + // history when it is empty (e.g. the plugin loaded mid-turn) or stale. + let text = w.lastAssistantText + if (!text) text = await lastAssistantTextFromContext(sid) if (!text) return if (containsToolCallAsText(text)) { @@ -768,13 +793,24 @@ export default Plugin.define({ const sid = sidOf(ev) if (!sid) return const w = ensureWatch(sid) - // Only user interrupts should suppress us; ours reset flags themselves. - w.userCancelled = !w.aborting + // Stable v2 carries the interrupt `reason`. Only a genuine user + // interrupt should suppress recovery; shutdown/superseded/inactivity + // (or a missing reason on older runtimes) must not. + const reason = typeof ev.data?.reason === "string" ? ev.data.reason : undefined + w.userCancelled = !w.aborting && (reason === undefined || reason === "user") w.recovering = false markIdle(sid) return } - case "session.deleted": + case "session.deleted": { + const sid = sidOf(ev) + if (!sid) return + sessions.delete(sid) + return + } + // v1 legacy: not emitted in v2 (reverts now surface as + // `session.revert.cleared` / `session.revert.committed`). Kept + // defensively so older runtimes still drop their watch state. case "session.reverted": { const sid = sidOf(ev) if (!sid) return @@ -918,10 +954,13 @@ export default Plugin.define({ } // Subscribe and pump events in the background (setup must not block). + // Stable v2 recommends passing an AbortSignal so the stream is torn down + // promptly on plugin unload instead of staying suspended in `for await`. let running = true + const eventAbort = new AbortController() const pump = async () => { try { - const stream = ctx.event.subscribe() + const stream = ctx.event.subscribe({ signal: eventAbort.signal }) for await (const raw of stream) { if (!running) return const ev = raw as unknown as V2Event @@ -943,9 +982,10 @@ export default Plugin.define({ `ready (opencode v2). timeout=${chunkTimeoutMs}ms interval=${checkIntervalMs}ms retries=${maxRetries} loop=${loopMaxContinues}/${loopWindowMs / 1000}s`, ) - // Cleanup: stop timers and the pump; OpenCode awaits this on disable/reload/shutdown. + // Cleanup: stop timers and the event pump; OpenCode awaits this on disable/reload/shutdown. return () => { running = false + eventAbort.abort() clearInterval(watchdog) sessions.clear() log("info", "stopped") From aa89252b3e0d3b99f7f40c4a5d25871d5239a170 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Thu, 17 Sep 2026 15:03:54 +0200 Subject: [PATCH 4/5] docs(v2): add install guide and stable migration notes - docs/v2/installing.md: step-by-step v2 install (drop-in + plugins entry), options reference, verification and troubleshooting - docs/v2/migration.md: verified stable-vs-beta delta (package rename, event surface, new session methods, subscribe signal) and updated test notes - README: point at the new install guide and stable API --- README.md | 17 ++++-- docs/v2/installing.md | 136 ++++++++++++++++++++++++++++++++++++++++++ docs/v2/migration.md | 102 ++++++++++++++++++++++++------- 3 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 docs/v2/installing.md diff --git a/README.md b/README.md index abb7ec7..dfe777e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ **Plugin for [OpenCode](https://github.com/anomalyco/opencode) that automatically detects and recovers from LLM session failures — stalls, broken tool calls, hallucination loops, stuck subagent parents, and more. Fully silent, zero UI pollution.** -> **OpenCode v2 is here.** This repo now ships both the v1 plugin (`src/index.ts`) -> and a complete v2 port (`src/v2/index.ts`). See [docs/v2/migration.md](docs/v2/migration.md) -> for the full migration notes, or jump to the [Installation](#installation) section. +> **OpenCode v2 is here (stable).** This repo ships both the v1 plugin +> (`src/index.ts`) and a complete v2 port (`src/v2/index.ts`) targeting the +> stable `@opencode/plugin` 2.0.5 API. Install it with the +> [v2 install guide](docs/v2/installing.md); see +> [docs/v2/migration.md](docs/v2/migration.md) for the full migration notes. ## What it does @@ -307,9 +309,9 @@ With options: } ``` -### OpenCode v2 +### OpenCode v2 (stable) -The v2 plugin uses the new `Plugin.define` API with `ctx.event.subscribe()` (AsyncIterable) instead of the v1 hooks-object pattern. Add to your `opencode.json`: +The v2 plugin uses the `Plugin.define` API with `ctx.event.subscribe()` (AsyncIterable) instead of the v1 hooks-object pattern, and targets the stable **`@opencode/plugin` 2.0.5** API (opencode v2.0.5+). Add to your `opencode.json`: ```jsonc { @@ -327,7 +329,10 @@ The v2 plugin uses the new `Plugin.define` API with `ctx.event.subscribe()` (Asy Or place `src/v2/index.ts` directly in `~/.config/opencode/plugins/` for auto-discovery (no config entry needed). -Disable via `"-auto-resume.v2"` in `plugins`. Full v2 migration notes: [docs/v2/migration.md](docs/v2/migration.md). +Disable via `"-auto-resume.v2"` in `plugins`. + +- **Step-by-step install guide:** [docs/v2/installing.md](docs/v2/installing.md) +- **Migration notes (v1 → v2, stable validation):** [docs/v2/migration.md](docs/v2/migration.md) ### Configurable options diff --git a/docs/v2/installing.md b/docs/v2/installing.md new file mode 100644 index 0000000..00af2d8 --- /dev/null +++ b/docs/v2/installing.md @@ -0,0 +1,136 @@ +# Installing opencode-auto-resume on OpenCode v2 + +This guide installs the **v2 port** of the plugin. It targets the stable v2 +plugin API (`@opencode/plugin` 2.0.5, shipped with `opencode` v2.0.5). + +> Coming from OpenCode v1? Read [`migration.md`](./migration.md) first — the v1 +> plugin file does **not** run on v2 and must be replaced by the v2 port. + +## Requirements + +- **opencode v2.0.5 or newer** (`opencode --version`). +- Node.js/Bun is only needed if you run the test/typecheck tooling; the plugin + itself is loaded by opencode. +- The plugin source: [`src/v2/index.ts`](../../src/v2/index.ts) from this repo. + +## Install + +### Option A — drop-in file (no config) + +OpenCode auto-loads every plugin found in these directories: + +- Global: `~/.config/opencode/plugins/` +- Per project: `/.opencode/plugins/` + +Copy the v2 port there: + +```sh +mkdir -p ~/.config/opencode/plugins +cp src/v2/index.ts ~/.config/opencode/plugins/auto-resume-v2.ts +``` + +That's it — no `opencode.json` change is required. Restart opencode (or reload +plugins) and the plugin is active. + +### Option B — `opencode.json(c)` entry + +Use this when you want to load the file from another location, pass options, or +pin a published package. Add an entry to the `plugins` array: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + // 1. plain path (relative to the config file) or absolute path + "./plugins/auto-resume-v2.ts", + + // 2. with options + { + "package": "./plugins/auto-resume-v2.ts", + "options": { + "chunkTimeoutMs": 45000, + "maxRetries": 3 + } + } + ] +} +``` + +Both `.opencode/plugin/` (v1 directory name) and `.opencode/plugins/` are +discovered; use `.opencode/plugins/` for v2 files. + +## Options + +All options are optional and are read from `ctx.options` in `setup`. + +| Option | Default | Description | +|---|---|---| +| `chunkTimeoutMs` | `45000` | Silence on a **busy** session before recovery is considered. | +| `gracePeriodMs` | `3000` | Extra grace added to the timeout before acting. | +| `checkIntervalMs` | `5000` | Watchdog polling interval. | +| `maxRetries` | `3` | Resume attempts per stall before escalating. | +| `baseBackoffMs` | `1000` | Base delay for exponential backoff between attempts. | +| `maxBackoffMs` | `8000` | Ceiling for the backoff delay. | +| `loopMaxContinues` | `3` | Continues allowed inside `loopWindowMs` before forcing interrupt + resume. | +| `loopWindowMs` | `600000` | Window (ms) for the hallucination-loop guard (default 10 min). | +| `maxRecoveryRetries` | `2` | Cap for targeted recovery prompts (tool-as-text / intent nudges). | +| `continuePrompt` | `"continue"` | Prompt used for a plain resume / ready-to-continue nudge. | +| `toolTextRecoveryPrompt` | _(built-in)_ | Prompt used when a tool call is printed as text instead of executed. | +| `doneWithoutWorkPrompt` | _(built-in)_ | Prompt used to verify a suspicious terse "done" claim. | +| `actionIntentPrompt` | _(falls back to `continuePrompt`)_ | Prompt used when the model ends with an unexecuted intent. | +| `debug` | `false` | Verbose `[auto-resume:debug]` logging. | + +Example: + +```jsonc +{ + "plugins": [ + { + "package": "./plugins/auto-resume-v2.ts", + "options": { "chunkTimeoutMs": 60000, "debug": true } + } + ] +} +``` + +## Verify it loaded + +Start opencode; the plugin logs its banner at load: + +``` +[auto-resume] ready (opencode v2). timeout=45000ms interval=5000ms retries=3 loop=3/600s +``` + +To see an intervention in action, let a session go quiet past +`chunkTimeoutMs`; the plugin injects a visible **synthetic** message in the +session timeline (`auto-resume: …`) and resumes the turn. Every recovery is +also appended to the opencode log with an `[auto-resume]` prefix. + +## Disable / uninstall + +- **Disable by id** without touching other plugins: add `"-auto-resume.v2"` to + the `plugins` array. +- **Temporarily off**: delete/move the file out of the `plugins/` directory. +- **Uninstall**: remove the file and any `plugins` entry you added. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| No `[auto-resume] ready …` banner | File is in a `plugins/` dir opencode scans, or listed in `plugins`; restart opencode. | +| Nothing happens on a stall | Increase verbosity with `"debug": true`; confirm `chunkTimeoutMs` isn't larger than your real stall. | +| Recovers but you don't see a notice | Your model/provider may reject `session.synthetic()`; the plugin falls back to `session.prompt()` (no TUI banner). | +| Never recovers a parent waiting on a subagent | Intentional: parent sessions blocked on a running subagent are left alone. | +| Never recovers while a permission dialog is open | Intentional: recovery is held until the permission is answered. | + +## Development + +```sh +# typecheck the v2 port against the stable types +bun add -d @opencode/plugin@2.0.5 typescript +bunx tsc --noEmit --strict --target ESNext --module ESNext \ + --moduleResolution bundler --skipLibCheck src/v2/index.ts +``` + +See [`migration.md`](./migration.md) for the full v1→v2 mapping and the +stable-vs-beta validation notes. diff --git a/docs/v2/migration.md b/docs/v2/migration.md index e3c52f2..c1ebd82 100644 --- a/docs/v2/migration.md +++ b/docs/v2/migration.md @@ -1,17 +1,18 @@ # opencode-auto-resume — OpenCode v2 port: what changed -> Saved from ~/.config/opencode/plugins/README.md on 2026-08-25. > Drop this into the PR description (or keep as docs/v2-migration.md upstream). > Target: https://github.com/Mte90/opencode-auto-resume -> Runtime tested against: opencode2 v0.0.0-beta-18050, @opencode-ai/plugin 0.0.0-next-17403 +> Runtime: **opencode v2.0.5 (stable)** with **@opencode/plugin 2.0.5**. +> Originally ported against opencode2 v0.0.0-beta-18050 / @opencode-ai/plugin +> 0.0.0-next-17403; re-validated against the stable release (see §7). ## Summary Ports the plugin from the v1 hooks API (`Plugin` factory returning a hooks object) to the v2 promise-plugin API (`Plugin.define({ id, setup })` + `ctx.event.subscribe()`). All detection/recovery features are preserved. -Strict-mode typechecked against the real installed `@opencode-ai/plugin` -types; validated by a mocked-context runtime suite covering every recovery +Strict-mode typechecked against the real `@opencode/plugin@2.0.5` types; +validated by a mocked-context runtime suite covering every recovery path (12/12 checks). ## 1. Config key renamed: `plugin` → `plugins` @@ -46,7 +47,7 @@ export const MyPlugin: Plugin = async ({ client, $, directory }) => ({ // v2: default-export Plugin.define with a unique id and a setup fn. // setup registers long-lived behavior and MAY return a cleanup function, // which OpenCode awaits on disable/reload/shutdown. -import { Plugin } from "@opencode-ai/plugin" +import { Plugin } from "@opencode/plugin" export default Plugin.define({ id: "acme.thing", // unique — used by disable directives @@ -67,7 +68,7 @@ The v2 context is essentially a typed server client plus registration APIs: | v2 capability | Replaces (v1) | |---|---| | `ctx.event.subscribe()` → **AsyncIterable** of events | `event` hook | -| `ctx.session.prompt/interrupt/create/get/command/synthetic/generate` + `ctx.session.hook("context")` | direct SDK calls / chat message hooks | +| `ctx.session.prompt/interrupt/create/get/command/synthetic/generate` + `context/wait/switchAgent/switchModel/rename/update/move` + `ctx.session.hook(...)` | direct SDK calls / chat message hooks | | `ctx.tool.transform` / `ctx.tool.hook` | `tool` map, `tool.execute.before/after` | | `ctx.agent.transform` | agent config mutation | | `ctx.options` | second `options` argument of the v1 factory | @@ -79,9 +80,12 @@ Notes: - **Flattened SDK calls**: nested `{ path, query, body }` envelopes are gone. Example: `session.prompt({ path: { id }, body: { parts } })` became `session.prompt({ sessionID, text })`. -- **No history access**: the v2 plugin context does not expose - `message.list()`. Plugins that need assistant text must accumulate it from - streaming events (`session.text.delta`, `session.reasoning.delta`). +- **History access**: beta exposed no message-history API, so plugins that + needed assistant text had to accumulate it from streaming events + (`session.text.delta`, `session.reasoning.delta`). **Stable adds + `ctx.session.context({ sessionID })` → `SessionMessageInfo[]`**; the port + keeps the deltas for liveness and uses `context()` as the authoritative + final text at idle time. - **Logging**: write to the console with a prefix instead of `app.log()`; opencode captures stdout/stderr into its logs. @@ -90,9 +94,20 @@ Notes: v1 events were `{ type, properties }`; v2 events are flat `{ type, created, data }`. Useful v2 session events for watchdog-style plugins: `session.execution.started/succeeded/failed/interrupted`, -`session.step.started/ended/failed`, `session.text.delta`, -`session.reasoning.delta`, `session.tool.called/progress/success/failed`, -`session.retry.scheduled`, `session.idle`, `permission.asked/replied`. +`session.step.started/streamed/ended/failed`, `session.text.started/delta/ended`, +`session.reasoning.started/delta/ended`, +`session.tool.input.started/delta/ended`, +`session.tool.called/progress/success/failed`, `session.shell.started/ended`, +`session.retry.scheduled`, `session.compaction.*`, `session.status`, +`session.idle`, `session.deleted`, `permission.asked/replied`. + +Two shape details the port relies on: + +- `session.execution.interrupted` carries `data.reason` + (`"user" | "shutdown" | "superseded" | "inactivity"`) — only `"user"` should + suppress recovery. +- `session.idle` is a separate ephemeral event (`data.sessionID`); + `session.status` carries `data.status.type` (`"idle" | "retry" | "busy"`). ## 5. Concrete changes in this port @@ -100,10 +115,14 @@ plugins: `session.execution.started/succeeded/failed/interrupted`, background `ctx.event.subscribe()` pump; cleanup returned from `setup` clears the watchdog interval. - `client.session.prompt({path, body})` → `ctx.session.prompt({ sessionID, - text })` (a nested-shape fallback is kept while the beta API settles). + text })` (flat input; the beta-era nested-shape fallback was removed once + stable confirmed the flat contract). - `session.messages()` forensics → live accumulation of assistant text from - `session.text.delta` / `session.reasoning.delta` events (message history - is not reachable from the v2 plugin context). + `session.text.delta` / `session.reasoning.delta`, with + `ctx.session.context({ sessionID })` as the authoritative idle-time read. +- Recovery notifications use `ctx.session.synthetic({ sessionID, text, + description, resume })` so the intervention is visible in the TUI, falling + back to `session.prompt({ sessionID, text })`. - `ctx.client.app.log(...)` → prefixed console logging. - Status polling demoted: session state comes primarily from lifecycle events; a low-frequency interval cross-checks active sessions for silence @@ -116,6 +135,10 @@ plugins: `session.execution.started/succeeded/failed/interrupted`, - Failure-triggered recoveries arm a flag so the failure's own idle transition doesn't cancel the scheduled retry prompt, while a healthy completion still cancels stale ones. + - `ctx.event.subscribe({ signal })` + `AbortController` so the event stream + is torn down on unload instead of staying suspended in `for await`. + - User-interrupt detection uses `session.execution.interrupted`'s `reason` + (stable) instead of assuming every non-plugin interrupt was the user. ## Feature parity retained @@ -130,7 +153,9 @@ respected. Unchanged names/defaults from v1: `chunkTimeoutMs` (45000), `gracePeriodMs` (3000), `checkIntervalMs` (5000), `maxRetries` (3), `baseBackoffMs` (1000), `maxBackoffMs` (8000), `loopMaxContinues` (3), `loopWindowMs` (600000), -plus `maxRecoveryRetries`, `continuePrompt`, `debug`. +`maxRecoveryRetries` (2), `debug` (false), plus the prompt overrides +`continuePrompt`, `toolTextRecoveryPrompt`, `doneWithoutWorkPrompt`, +`actionIntentPrompt`. ```jsonc { @@ -147,9 +172,42 @@ Disable by id without touching other plugins: add `"-auto-resume.v2"`. ## Testing -- `tsc --strict --noEmit` clean against `@opencode-ai/plugin@0.0.0-next-17403`. -- Mocked-context runtime suite (bun): export shape · stall watchdog → prompt · - ready-to-continue nudge · tool-call-as-text recovery · loop guard → interrupt · - permission-hold blocks recovery · execution-failure recovery · stale recovery - does not disturb healthy idle sessions · user interrupt honored · cleanup - quiesces timers — **12/12 passing**. +- `tsc --strict --noEmit` clean against **`@opencode/plugin@2.0.5`** (stable). +- Mocked-context runtime suite (bun, 12 tests) against the stable package: + definition shape · `subscribe({ signal })` · abort-on-cleanup · stall watchdog + → `synthetic` with visible `description` + `resume` · idle forensics via the + `session.context()` fallback · healthy idle turn is a no-op · permission-hold + blocks recovery · `interrupted` `reason="user"` disables recovery · + `reason="inactivity"` does **not** · execution-failure recovery · + `synthetic`→`prompt` fallback · `session.deleted` drops state — + **12/12 passing**. + +## 7. Re-validated against stable v2 (2.0.5) + +The port was originally written against the beta (`@opencode-ai/plugin` +`0.0.0-next-17403`). Re-checked against the stable release: + +- **Package renamed**: the dependency is now **`@opencode/plugin`** (stable + `2.0.5`), published from the opencode repo. The beta `@opencode-ai/plugin` + package is not the stable API. Imports must use `@opencode/plugin`. + Config/discovery is unchanged (`plugins` key, object form, and plugins under + `.opencode/plugin/` **or** `.opencode/plugins/` are auto-loaded). +- **Every event the plugin matches still exists** in 2.0.5, with the same names + (`session.execution.succeeded` is still `succeeded`, not `completed`). New, + unused-by-us events: `session.status`, `session.text.started`, + `session.step.streamed`, `session.tool.input.*`, `session.compaction.*`, + `session.message.content.updated`. +- **`session.execution.interrupted` gained `data.reason`**; the port now treats + only `"user"` as a user-cancel. +- **`session.reverted` is gone** in v2 (reverts surface as + `session.revert.cleared` / `session.revert.committed`); the legacy matcher is + kept defensively and `session.deleted` handles cleanup. +- **New session methods** on the plugin context: `context()` (message history), + `wait()`, `switchAgent()`, `switchModel()`, `rename()`. The port adopts + `context()` for idle-time forensics; `wait()` is intentionally unused (the + event-driven watchdog is retained). +- **`ctx.session.synthetic()` still accepts `description` and `resume`** in its + input (both optional), so the visible-notification + resume path is unchanged. + `ctx.session.interrupt()` input is `{ sessionID, resume? }`. +- **Docs recommend `ctx.event.subscribe({ signal })`** and aborting the stream + during cleanup; the port now does this with an `AbortController`. From b5fd8aa5bf7d59550c401da4b64a382db09fe28f Mon Sep 17 00:00:00 2001 From: valentimarco Date: Thu, 17 Sep 2026 15:07:40 +0200 Subject: [PATCH 5/5] docs(v2): document @opencode/plugin resolution requirement A local .ts plugin is imported by opencode with normal ESM resolution and the plugin dependency graph is resolved at server startup, so @opencode/plugin must be installed in the config dir (~/.config/opencode) and opencode restarted. Without it the log shows "failed to load plugin ... Cannot find package '@opencode/plugin'". --- docs/v2/installing.md | 37 +++++++++++++++++++++++++++++++++---- docs/v2/migration.md | 6 ++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/v2/installing.md b/docs/v2/installing.md index 00af2d8..f82263a 100644 --- a/docs/v2/installing.md +++ b/docs/v2/installing.md @@ -9,13 +9,35 @@ plugin API (`@opencode/plugin` 2.0.5, shipped with `opencode` v2.0.5). ## Requirements - **opencode v2.0.5 or newer** (`opencode --version`). -- Node.js/Bun is only needed if you run the test/typecheck tooling; the plugin - itself is loaded by opencode. +- **`@opencode/plugin` must be resolvable by opencode.** A local plugin is loaded + with a normal ESM import, so the API package has to be installed next to it + (see [Step 1](#step-1--install-the-plugin-api-package)). Without it opencode + logs `failed to load plugin … Cannot find package '@opencode/plugin'`. +- Node.js/Bun is only needed to install the API package and run the + test/typecheck tooling; opencode itself loads the plugin. - The plugin source: [`src/v2/index.ts`](../../src/v2/index.ts) from this repo. ## Install -### Option A — drop-in file (no config) +### Step 1 — install the plugin API package + +Global plugins resolve imports from `~/.config/opencode`, so install the stable +API package there once: + +```sh +cd ~/.config/opencode +bun add @opencode/plugin@2.0.5 # or: npm install @opencode/plugin@2.0.5 +``` + +For a per-project plugin, install it in the project instead: + +```sh +npm install @opencode/plugin@2.0.5 +``` + +### Step 2 — add the plugin + +#### Option A — drop-in file (no config) OpenCode auto-loads every plugin found in these directories: @@ -32,7 +54,7 @@ cp src/v2/index.ts ~/.config/opencode/plugins/auto-resume-v2.ts That's it — no `opencode.json` change is required. Restart opencode (or reload plugins) and the plugin is active. -### Option B — `opencode.json(c)` entry +#### Option B — `opencode.json(c)` entry Use this when you want to load the file from another location, pass options, or pin a published package. Add an entry to the `plugins` array: @@ -59,6 +81,12 @@ pin a published package. Add an entry to the `plugins` array: Both `.opencode/plugin/` (v1 directory name) and `.opencode/plugins/` are discovered; use `.opencode/plugins/` for v2 files. +### Step 3 — restart opencode + +Restart opencode after installing. The plugin loader resolves plugin +dependencies when the server starts, so a hot file reload is **not** enough to +pick up a newly installed `@opencode/plugin` package. + ## Options All options are optional and are read from `ctx.options` in `setup`. @@ -118,6 +146,7 @@ also appended to the opencode log with an `[auto-resume]` prefix. | Symptom | Check | |---|---| | No `[auto-resume] ready …` banner | File is in a `plugins/` dir opencode scans, or listed in `plugins`; restart opencode. | +| Logs say `failed to load plugin … Cannot find package '@opencode/plugin'` | Install the API package next to the plugin (Step 1) **and restart** opencode. | | Nothing happens on a stall | Increase verbosity with `"debug": true`; confirm `chunkTimeoutMs` isn't larger than your real stall. | | Recovers but you don't see a notice | Your model/provider may reject `session.synthetic()`; the plugin falls back to `session.prompt()` (no TUI banner). | | Never recovers a parent waiting on a subagent | Intentional: parent sessions blocked on a running subagent are left alone. | diff --git a/docs/v2/migration.md b/docs/v2/migration.md index c1ebd82..907bfee 100644 --- a/docs/v2/migration.md +++ b/docs/v2/migration.md @@ -211,3 +211,9 @@ The port was originally written against the beta (`@opencode-ai/plugin` `ctx.session.interrupt()` input is `{ sessionID, resume? }`. - **Docs recommend `ctx.event.subscribe({ signal })`** and aborting the stream during cleanup; the port now does this with an `AbortController`. +- **Local-file installs need the API package resolvable.** opencode loads a + local `.ts` plugin with a normal ESM import and snapshots plugin + dependencies at server startup, so `@opencode/plugin` must be installed in + the config dir (`~/.config/opencode`) and opencode restarted. A *published* + plugin should instead declare `@opencode/plugin` in its `dependencies` + (replacing `@opencode-ai/plugin`).