Repository navigation
Conversation
- 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.
…ilure 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: <kind>" (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.
- 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.*)
- 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
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'".
Mirrors the v1 src/index.ts fix onto the v2 port: - oocLocked / oocLockReason / selfRecovery on SessionWatch - OOC_ERROR_RE (exceeds the available context size / too large to compact / too many tokens / prompt is too long) - maybeLockOoc latches on retry.scheduled + failed events carrying an OOC error; recover() and tryAbortAndResume refuse while locked - execution.started from a genuine (non-self-recovery) run clears the lock; self-recovery busy edges don't reset the retry counter v1 full suite on this branch: 578 pass / 0 fail (v1 source untouched). v2 build: clean.
Root cause: recovery injects a synthetic 'continue' turn to a session
even while it is BUSY; in v2 a new turn interrupts the in-flight step
('Step interrupted'). The 45s stall watchdog falsely fires during long
27B thinking and native compaction (little/no text.delta), so in-flight
compaction was interrupted 3x back-to-back.
FIX A (compaction guard):
- SessionWatch.compacting / compactionStartedAt set on
session.compaction.started, cleared on ended/failed and on
execution.interrupted (feature-tolerant: no-op if the runtime
emits no such events), stale-cleared after 30 min
- refuse recovery in recover(), tryAbortAndResume(), targetedRecovery();
watchdog skips stall detection while compacting
FIX B (live-busy inject guard):
- at inject time, skip the synthetic turn if the session is still
busy AND live within the chunk window: a working session is not
stalled; the watchdog re-arms if it truly stalls
|
While landing the v2 "Step interrupted" fix I checked the v1 plugin (
Bottom line: v1 is unaffected; no back-port to v1. The v2 fix ( |
A turn that ends with a question or an explicit prompt is awaiting the user's reply. Nudging it (a synthetic 'continue') starts an in-flight step that the user's slow reply then interrupts -> 'Step interrupted'. Add isUserHandoff() and gate inspectOnIdle() on it. Purely additive; legit nudges (tool-as-text / ready-to-continue / non-hand-off done-claim) are unchanged.
|
Added a hand-off guard in 0dd0403: isUserHandoff() detects a turn that cleanly ends by handing control back to the user — a question, or an explicit prompt — and inspectOnIdle() now skips its targeted continue nudge for such a turn. This closes the last source of "Step interrupted": previously the plugin could inject a synthetic continue right after a hand-off turn (its last assistant message matched a tool-call/done-claim heuristic), starting an in-flight step; when the user then answered the question, their reply interrupted that step. A hand-off turn is, by design, awaiting the user, so it is never nudged now. Purely additive — no change to the existing stall / compaction / OOC-loop guards. tsc clean; running on my box (md5 226d48ed63757983f4fd4c4512e30fcc). |
…n a pending tool_use/question or recent user activity; + activeUserWindowMs option (default 15min)
…e bursts
The plugin could interrupt a session and then recover from its own interrupt,
injecting a synthetic `continue` that superseded the step it had just killed.
The UI surfaces that as "Opencode failed to send message with error: Step
interrupted".
Root causes fixed here:
* Interrupt-shaped failures were never recognised. The filter only matched
"interrupted by user" / a type containing "cancel", but v2 reports an
interrupt as {type:"aborted", message:"Step interrupted"}, so the plugin
recovered from its own abort forever. Added isAbortError() and made the
plugin refuse to recover from any abort-shaped failure, its own or the
user's.
* "Is this abort mine?" was tracked with a transient boolean that the runtime
resets before it delivers session.execution.interrupted asynchronously.
Replaced with a selfAbortAt latch + TTL, and the idle handler no longer runs
its heuristics on top of our own abort.
* There was no single choke point for recovery injection: the stall watchdog,
the intent/tool-loop nudges and the abort+resume escalation each called
notifyAndPrompt directly, with no rate limit. Added injectOnce(), which all
three paths now use, refusing injections inside our own abort window, into a
busy-but-live session (a turn sent to a busy session supersedes its in-flight
step), and more often than injectIntervalMs (15s). The abort+resume
escalation takes its own lane, since swallowing its continue would leave a
session interrupted with nothing to restart it.
* The loop guard counted the plugin's own continues, so it escalated on its own
output; and targetedRecovery recorded the same continue twice. The guard is
now evaluated before recording.
* getActiveSessions() returned [] because v2's plugin SessionDomain does not
expose session.active, which made the subagent-wait guard permanently dead —
a parent that went quiet while its child ran was declared stalled and
interrupted. Falls back to busySessions(), derived from our own busy flags.
* Interrupts are now hard-capped per window, since an interrupt persists an
errored assistant message that the UI shows as a send failure.
Also:
* Added the missing liveness events (session.step.streamed, *.started,
session.synthetic, session.tool.input.*) so a step that starts and then
produces no tokens, or a long tool-input generation, is not mistaken for a
stall.
* Removed dead code: waitingOnSubagent (never set true, so its guard could
never fire) and maxRecoveryRetries (read from options, never used).
* Made this file buildable: it imported a runtime Plugin.define from
"@opencode/plugin", which is not a dependency of this repo. Use a local
identity helper instead; `tsc --noEmit` now exits 0 where it previously
failed with 2 errors.
* log() now prefers ctx.app.log, since plugin console output is not captured
anywhere retrievable.
Verified with tsc (exit 0), the existing test suite (578 passing, 0 failing)
and a behavioral harness that asserts zero injections during healthy
streaming, zero after a user stop, zero in response to the plugin's own abort
events, and a working stall recovery path.
|
i will try the branch. Also on my code there was a bug where the tools (like questions) where interruped, will update if occurs again |
…ng (valentimarco reply); upstream +1 (e1b8374)
The v2 stand-down guard could never fire. It tested `part.state.status === "pending"`, but the v2 message projection never writes "pending" — an in-flight tool part reads "running" and settles to "completed" or "error" (`pending` was the v1 spelling). With that branch unreachable, auto-resume injected a synthetic `continue` over an unanswered `question`, which surfaced as the tool call being interrupted / "Step interrupted". Reported in Mte90#33. A second, independent defect: markIdle() cleared `permissionPending` immediately before inspectOnIdle() consulted it. The `session.idle` case calls markIdle() and then inspectOnIdle(), so the flag was always false by the time the guard that honours it ran — dead on exactly the transition it exists for. `permission.replied` is now the only thing that clears it, plus a TTL stale-clear mirroring the compaction one, so a `permission.asked` whose reply never arrives cannot strand a session forever. Interactive tools (`question`, `permission`, `ask`, `confirm`) stand down unless they reached "completed" — a `question` that came back "error" was interrupted, not answered. Verified against the real v2 message shape: ctx.session.context() returns a plain array, and the tool-part states actually observed in a live session are completed / error / running, never pending. Tests: src/v2/index.stand-down.test.ts drives the real v2 seam (event.subscribe -> async iterable, session.synthetic). It includes a mandatory control asserting recovery DOES fire when nothing is pending, plus over-blocking controls (answered question, non-interactive tool erroring, permission asked-then-replied) so the fix cannot silently disable auto-resume. 7 of its 13 assertions fail against the previous code. Full suite: 591 pass / 0 fail. tsc --noEmit clean.
|
@valentimarco the There were two independent defects behind it, both in 1. The stand-down guard could never fire. part?.state?.status === "pending"but the v2 message projection never writes It now stands down on 2. Two details worth knowing when reading the store, since both are easy to get wrong: Tests — |
…tate The port documented that v2 reverts surface as `session.revert.cleared` / `session.revert.committed` and kept a defensive legacy `session.reverted` case, but it never handled the v2 events themselves. On a real v2 runtime no case matched, so a revert left the session's watch state — including any armed recovery — intact against turns that had just been rewound. Verified against the running opencode server: all three names are declared event schemas with live store handlers. - `session.revert.staged` is intermediate (the server sets a `revert` field there), so it only counts as activity and keeps the state. - `session.revert.cleared` and `session.revert.committed` are terminal (the server clears that field on both) and drop the state, matching the existing `session.deleted` behaviour. The legacy `session.reverted` case stays for v1-shaped runtimes.
|
Small addition rather than a change: the port's revert handling documents the The switch has: // 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": {That comment names the two v2 events, but neither is handled anywhere in the Checked against a running v2 server — all three are declared event schemas with
The changeAdds the three events alongside your existing case (which stays byte-for-byte case "session.revert.staged": {
const sid = sidOf(ev)
if (!sid) return
touch(sid) // staging is activity, not terminal — keep the state
return
}
case "session.revert.cleared":
case "session.revert.committed": {
const sid = sidOf(ev)
if (!sid) return
sessions.delete(sid)
return
}Terminal stages drop the state, consistent with how the port already handles
If you'd rather keep this PR to the port itself, happy to split it into its |
|
One more v1-parity item for this port. The v1 plugin detects subagent sessions and excludes them from recovery. Two
The v2 port dropped both. Consequence: a child session (spawned as a subagent) I have the port-side fix on my fork, branch Happy to either fold this into PR #37 as a follow-up commit or open it as its |
…syStallStrategy Additive to the existing v2 port on this branch — no rebase, no changes to src/index.ts. The v1 side of the plugin is untouched here. The v2 port read 15 of v1's 31 options and silently ignored the other 16, so a v1 config ported to v2 quietly ran on defaults. Three changes close that: - All 31 v1 option names are now read. `maxRecoveryRetries` is accepted as a fallback for v2's `maxRetries`, so a v1 config needs no edits. - 8 of them are accepted but not applied, because the v2 feature they tune is not ported (no token read, no reachable todo state, no discovery sweep). They are listed as accepted-but-inert in the startup line and explained in docs/known-issues-v2.md rather than left to look like live behaviour. - Any option key this build does not know produces one warn line at startup. v1 dropped unknown keys silently, which is how the gap went unnoticed. busyStallStrategy is now complete rather than half-wired: "off" skips the stall and "abort" interrupts the wedged step before continuing, mirroring the v1 branch. Previously only "off" was handled, so "abort" silently behaved as "continue" while appearing in the recognised set. src/v2/index.options.test.ts covers all of the above behaviourally, with a control per group, plus a source contract that fails if the declared options type and the recognised set drift apart. 630 pass, 0 fail. Also mirrors two upstream changes the v2 build had not picked up: - e1b8374: targetedRecovery now counts an attempt only after the prompt is delivered, so a rejected send no longer burns a retry. - DEFAULT_ACTIVE_USER_WINDOW_MS 15 min -> 5 min, matching v1. build:v2 was missing from package.json, so dist/v2/ could not be rebuilt from this branch at all. Added, along with dist/v2/index.js in files.
…t all
Two features ported from v1, and one defect fixed that made the plugin
unobservable.
Session discovery. v1 swept session.list() on an interval and trusted a
`status` field on each row. v2 has something better — session.active() is the
server's own record of what is running — so the sweep seeds a watch for every
existing session and adopts the running ones as busy. Without it, a turn that
was already mid-flight when the plugin attached produced no
`session.execution.started` for us to see, so the stall watchdog had nothing
to inspect and a wedged session stayed wedged. Both calls are read
defensively: the v2 plugin `session` domain is a narrowed Pick that omits
`list` and `active`, so each falls back to the raw client, and a host that
supplies neither degrades to the event-derived busy set rather than failing.
`discoveryDelayMs` is live again as a result, so it leaves the
accepted-but-inert list (7 remain, down from 8).
Logging. v1 logged through `ctx.client.app.log({ body: {...} })`, a real
endpoint that reached the opencode log file. v2 removed it — `ctx.app` is
`{name, version, channel}`, there is no `app` group in the protocol, and a
hosted plugin's console.log is not captured by OpenChamber. The v2 build was
therefore completely silent: the deployed one had zero log lines, and its only
startup line predated the current work by days. A running watchdog and a dead
one were indistinguishable, which is exactly what made a stall look like
"nothing happened". This build appends to
`~/.local/state/opencode-v2/auto-resume.log` (override with the new `logFile`
option or `AUTO_RESUME_LOG_FILE`), capped at 2 MB, every write best-effort so
an unwritable directory can never break the watchdog. Console output is kept
alongside it.
src/v2/index.discovery.test.ts covers both API shapes with a control per group,
the `{data}` envelope, malformed rows, a throwing list(), and teardown leaking
no timers. The option tests now read the log file rather than a stubbed
`ctx.app.log`, so they exercise the real sink. 639 pass, 0 fail.
Docs follow the code: known-issues-v2.md drops the stale discoveryDelayMs row
and documents both features, and README's activeUserWindowMs row now says
plainly that v1 on this branch is still 15 min while v2 is 5 min.
The stall watchdog only inspects busy sessions, so a turn that ends cleanly with the work unfinished is invisible to it by construction — no stall, no error, no streaming failure, just idle. Two v1 detectors for that case are ported onto the idle path. A done-claim with no work report in it. v2 gated the details prompt on a 400-character threshold, which cannot tell "Task done." from a real two-line summary: it over-nudged terse reports and let short real ones pass. It now uses v1's containsWorkDescription — a backticked or bare path with a dotted extension, or a report header (Changed / Verification / Tests run / Results / Commands run). Asking again after a genuine report loops forever (Mte90#26). A trailing 🎉. The model's own "finished" signal, so completion latches instead of being nudged over. Per-turn: a new turn re-opens the question, and a later idle with no new text does not re-derive it forever. That exposed a second defect in activeUserWindowMs. The default was lowered to 5 minutes to match e1b8374, but the implementation asked "is the newest message a user message?" instead of v1's "was any inbound user message inside the window?". At idle time the newest message is the assistant turn that just finished, so the option never applied and the plugin nudged straight over a user who was mid-conversation. It now walks back for the most recent user message. dbg() also goes to the log file now, for the same reason the rest of the logging does: debug output is exactly when you are working out what the plugin did, and console-only output in v2 reaches nobody. src/v2/index.premature-stop.test.ts, 17 tests: a control per group, plus the two halves of the length heuristic, one-nudge-per-turn, budget re-arm, maxRetries, latch survival, latch reset, the handoff and active-user guards, and the session.context() fallback. 656 pass, 0 fail.
…does A session can fill its context window without ever looking stalled — it just keeps working until it chokes. v1 caught that on idle by comparing the token count against the model's usable window. The v2 port declared the option inert because there was nothing to compare against; there was, it just needed reading. Tokens: session.usage.updated carries the session's current usage. tokenTotalOf sums input + output + reasoning + cache.read + cache.write, which is exactly TokenUsage.total in the schema and exactly what v1 accumulated. The window: ctx.model.get(providerID, modelID) hands over Model.Info.limit directly, where v1 had to walk the raw provider list. Usable is limit.context - Math.min(20_000, limit.output) — v1's arithmetic, unchanged, so the same threshold means the same thing on both builds. Cached per model. Routing is v1's. A subagent, with subagentNativeCompactionEnabled, requests native compaction: v1 called session.summarize(), v2 spells it session.compact, and it is not on the plugin session Pick, so it goes through the same defensive lookup that carries discovery. A parent goes to magic-context's ctx-wrapup command, but only when magic-context is installed — its setup disables native compaction, so compacting here would double-compress. v1 detected that with config.get().plugin; v2 removed the config domain, so it now reads ctx.plugin.list() and matches both the plugin id and its source spec, which is what makes it work for a local path and a package target alike. Both paths are one-shot per turn and fail-safe: no limit, no token count, a user cancellation, or a signalled completion means no intervention. contextSaturationThreshold and subagentNativeCompactionEnabled are live, so they leave the accepted-but-inert list (5 remain, down from 8). 16 new tests with a control per group, covering both routings, both compaction transports, the package-spec and local-path magic-context shapes, the one-shot budget, the user interrupt guard, the reserve arithmetic, cache and reasoning in the total, the threshold as a fraction, and the missing-model and missing-inventory fallbacks. 672 pass, 0 fail.
…e long calls a08b674 sent the stuck prompt ("proceed without it") from the stall timer when history showed an old unfinished tool. Age cannot distinguish a wedged call from a legitimately long one: it fired twice on a live 437s `bun test` shell — a working call older than the 60s gate — telling the user to abandon work still running while asserting "the turn went idle" on a busy session. The correct discriminator is completion state (tool.execute.before/after callID + Context.progress), which the plugin does not track yet. Until it does, the stall sends the generic continue and the stuck pass stays idle-path-owned. scanToolParts/buildStuckNudgePrompt stay for the idle path. Also reverts the uncommitted DIAG instrumentation in the same test file.
A shell stays event-silent for its whole run — a full `bun test` runs 437s with no events — so the flat 60s young gate declared live work wedged: 2026-10-11, three stall injections in nineteen minutes onto a session running its suite, one telling the user to abandon the run. stallYoungAgeMsFor gives shells 10 minutes on the STALL path (event inflightTools check + hasYoungUnfinishedWork); past that a shell is wedged for real and the stall answers normally. Interactive tools keep the 60s gate. The idle stuck pass keeps its flat 60s gate for every tool: a call unfinished when the turn ENDED is abandoned, not running — this changes when a busy stall fires, never whether an abandoned call gets nudged. Tests: new shell-120s-defers case (red-proven: injected without the fix) + write-120s CONTROL proving the interactive gate still fires; the old-part and user-wait CONTROL fixtures move shell->write so they keep pinning the 60s gate instead of the new shell TTL. An earlier attempt gated the stall timer on user recency instead; two CONTROLs with 60s-old fixtures proved recency cannot separate a live conversation from a fed stall, and it was reverted — that history is recorded in the shell-TTL test comment, not re-litigated.
"a genuine turn drops the durable arm" exhausted its 10s poll ceiling twice at ~10.5s under the full 85-file serial suite while the mechanism proved sound everywhere else (3/3 alone, green in 2/8/16/20-file subsets; the drop chain is microtask-only). Serial execution with disposed teardowns rules out cross-file pollution, so the ceiling — not the mechanism — broke. 30s poll / 60s test timeout keeps the failure mode (a record that never drops still fails). Full suite 998/0.
The idle evaluation judges "whatever is newest" (w.lastAssistantText || history read), which goes stale across markBusy transitions — proven 2026-10-12 when a TASK COMPLETE tail evaluated "not a completion claim" 197ms post-idle. If the idle event carries the ended turn's message id, the evaluation can read that turn instead of a snapshot. Logging only, zero behavior change; remove once answered.
The recover timer arms on detection and fires after the backoff delay, but
nothing re-checked for user takeovers in between: a genuine user message
landing inside the window still got injected into ("Step interrupted"). The
busy-freshness check cannot see it — event-pump lag means their
execution.started may not have been processed yet — so the timer compares
lastGenuineUserAt against its own arm time. Own prompts never advance that
clock, so our own injections cannot trip it. Covers both the inject and the
abort-escalation paths below it.
Test drives a 1000ms backoff window (deterministic to hit), lands a genuine
user message as history-only inside it — the exact shape the freshness
check is blind to — and asserts the guard logged plus zero injections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
noteInboundUserMessage stored message timestamps verbatim in lastGenuineUserAt. A forged or skewed future timestamp then keeps `lastGenuineUserAt > armedAt` true indefinitely, parking every later recover timer — the mid-backoff user-takeover guard's comparison never goes false. The fuse suite forges +35s deliberately (to defeat the own-prompt guard) and failed exactly this way; a skewed server clock does it by accident in production. One line: min(at, Date.now()). Genuine past timestamps pass through untouched, so production behavior is unchanged except for futures, which now read as "right now" — the most they can honestly claim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consecutive fires can all read "attempt 1/3" while being the 1st, 2nd, ... recovery of the episode, because progress restarts the ladder rung. Without the retry count those fires read byte-identically and cannot be reconciled from the transcript (2026-10-12: fourteen 1/3s in a row on one session). The head now reads `attempt 1/3 (retry Mte90#4)` using the existing episodeRefires counter; first fire of a fresh episode reads Mte90#1. Display-only: no detection, ladder, or backoff change. All existing `attempt N/3` assertions match on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A genuine user message processed in the SAME sweep as the stall declaration carries the sweep's own timestamp: lastGenuineUserAt == armedAt, and strict > let it through — firing into the user's live turn within the same minute (2026-10-12: comment 19:27, continue 19:28). Equal timestamps mean same-sweep causality, so the guard takes >=. Older messages are unaffected (still < arm). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`retry #N` was rendered from `episodeRefires + 1`, but episodeRefires only increments on progress restarts — so a pure climb (1/3, 2/3, 3/3 with no intervening progress) read retry Mte90#1 every time, and the number meant different things depending on invisible history. Now a dedicated episodeRecovers counter increments at every send decision in the stall timer and resets at the same genuine-episode sites, so the Nth recovery of the episode reads retry #N regardless of ladder resets. Same display slot, no detection or ladder change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
episodeRecovers counted every fire but reset only on genuine user messages, so a long session with model-initiated turns but no user input would climb toward absurd counts (retry #9999). Now a model turn not caused by us (a non-self execution.started) and the abort+resume escalation also restart it — the same observable-episode sites as the other latches. The stall ladder deliberately does NOT follow: its resets stay pinned to user messages, text growth, and abort (ses_ee14d727), so a revived turn still reads its true rung (attempt 2/3) while the count restarts (retry Mte90#1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ssing map The sessions map survives reloads, so a watch created before a field existed reads it as undefined. Shipped consequences (2026-10-12): `undefined++` rendered as "retry #NaN" in live nudge text, and `for..of undefined` on the missing inflightTools map would throw the whole watchdog sweep. ensureWatch now backfills the four post-sharing fields on the existing-watch path (not just at construction), and the sweep's map read tolerates a missing map for watches that arrive there before any ensureWatch call. Regression test strips the fields off the live watch object (simulating adoption) and asserts the next stall still fires with a numeric retry count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mple) v2 removed todowrite/todoread, so the unfinished-task nudges only fire when the installed todo plugin persists a list to the session log. Document the pairing, the optional nature of the plugin, and the exact nudge text.
…iling The latch only saw a terminal emoji, so a reply celebrating anywhere else — leading, mid-sentence — drew a stall nudge anyway (2026-10-12: reply ending in emoji still nudged). Now any 🎉 in the last three lines latches. The todo cross-check is what makes the wider net safe and is untouched: open todos still refuse the latch and get the reminder nudge instead. An emoji older than the tail belongs to earlier work and does not latch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boundary markers come in beginning/end pairs (model output habit, not platform behavior — neither opencode compaction nor DCP prompts emit emoji), so an anywhere-in-tail check latches on a closing marker that signifies structure, not completion. Back to end-anchored matching on the last non-blank line, with the todo cross-check untouched. Keeps the punctuation tolerance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Router block at top: combined v1+v2 README, which sections apply where. - v1/v2 marks on loop detector, in-flight guard, back-off lift, ESC mechanism, recovery-behavior block, state-machine fields, example scenario, done-claim todo source, spurious-error logging; dead recovery-flow.md link retargeted. - Architecture rewritten as the v2 event flow; v1 kept as history. - Busy-silence: rich prompt format documented with real example; v1-behavior opt-out; 2.0.5+ version claims with additive-only evidence. - Completion area: A/B operator instructions with placement, new Stop statements subsection (mined closers + negative case), emoji/stop latch parity stated once; missing option rows added (stuckToolAgeMs, neverInterruptTools, shareWatchState, registryScope, quietCorrections). - Todo section: v1-has-todos baseline, opencode-cli-only strip correction, /todo mention, fork names disambiguated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operators without an AGENTS.md have nowhere to put the completion instructions the plugin depends on. docs/AGENTS-snippet.md carries the A wording plus the two structural companions (todos, questions), each conditional on its capability existing. README points at it from the operator-instructions block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The usage.updated handler recorded the total but never touched the activity clock, so a turn generating for minutes straight still read as idle and drew a false stall injection mid-generation. Growing totals now touch the clock; a flat total means nothing resumed and does not.
Stored message text carries harness-appended @id@ boundary markers that defeat every end-anchored completion predicate. ses_ecca0ad6 ended nine TASK COMPLETE deliveries with a marker and the stall ladder fired on each despite the latch code being live. Trailing marker runs are now stripped in endsWithCelebration and containsStopStatement; mid-text mentions keep their meaning.
A provider rate-limit containing 'too many tokens' ('sent too many tokens
in a short period, please wait before retrying') matched the bare
alternative in OOC_ERROR_RE, latched oocLocked, and halted all recovery.
- Widen RATE_LIMIT_RE (throttle/please-wait/try-again-later plus bounded
'too many tokens'+wait/retry/period cues); genuine overflow phrasing
without those cues still latches.
- maybeLockOoc(sid, errMsg, errType) defers to isRateLimitError first, so
session.retry.scheduled is covered, not just session.execution.failed.
- New suite src/v2/index.ooc-throttle.test.ts: throttle stands down on the
ladder with no latch; genuine overflow still latches.
…ult 9) The maxRetries ladder restarts on model text, so a model that keeps replying 'done' reads 1/3 forever. The new lifetime count climbs through that chatter, stands the session down at the cap, and only resets on a genuine user message/turn.
… TASK COMPLETE case discipline - Broaden all/everything done-claim with one optional noun (All work complete) - Add stop patterns: standing-by class, ready-for-next-task class (latch, not continue) - Consolidate DEFAULT_DONE_CLAIM_PATTERNS + DONE_CLAIM_PATTERNS into single DONE_CLAIM_PATTERN_SOURCES (the string fallback had lost its anchors) - Bare TASK COMPLETE now case-sensitive (byte-exact per AGENTS-snippet); every other completion pattern stays case-insensitive; lowercase 'task complete' is model prose, past-tense 'task completed' still latches - 4 regression tests (S3/S14/S15 tails + lowercase CONTROL); file 11/11 green
…package 1.1.21) into PR37
Fire the tool.success event at 100ms (mid-ladder) instead of 300ms (after the default lifetime cap of 9 burns in ~230ms and stands the session down), and open lifetimeMaxContinues to 99 so the test exercises the exact-duplicate skip gate instead of racing the lifetime gate. Production code unchanged — the skip defeat was already correct.
|
Fix for the The 1/1026 failure ( Root cause: the test scheduled its Fix in
Deliberately did not touch lifetime-cap semantics — clearing it on tool completions would gut real loop protection. |
[PR] OpenCode v2 port (
src/v2/index.ts) on the stable@opencode/pluginAPIA second plugin module for the same recovery behavior, written against the stable v2 API.
src/index.ts(v1) is not touched: the PR's diff is 21 files, none of them v1.pr20-v2port(famewolf/opencode-auto-resume)src/v2/index.ts, 3,514 linesdocs/known-issues-v2.md,docs/v2/installing.md,docs/v2/migration.mdProblem
This plugin only works on OpenCode v1. On v2 the v1 file does not load at all — a different plugin API, so the entrypoint fails with
Cannot find package '@opencode/plugin'. Anyone who has moved to v2 has no auto-resume, and there is no partial option: the module either loads or it does not.What the v2 API required
ctx.client{ id, setup },setupreturns a cleanup fneventhook,{ type, properties }ctx.event.subscribe()AsyncIterable, flat{ type, data }ctx.client.session.*over HTTPctx.session.*; nolist/active, so discovery goes throughcallSessionApiwith client-side verificationsession.messages()pollingsession.text.delta, withctx.session.context()as the authorityctx.app.logTwo shape differences are worth naming because they would have made a detector inert rather than wrong, which is the harder failure to notice:
AssistantToolcarries its name inpart.namewith nocallID, and the tool lifecycle issession.tool.called(increment) against.success/.failed(decrement).Feature parity, and what running it live turned up
Every recovery path v1 has is here — stalled-stream watchdog, execution/step failure recovery, provider-retry awareness, tool-call-as-text detection, ready-to-continue and stalled-intent nudges, done-claim verification, the hallucination-loop guard, subagent and permission awareness, the visible recovery notification.
Parity was the bar. The first push was a port of the architecture rather than the behavior; running it live found the rest, one commit each. The ones that changed what the plugin does:
activeUserWindowMsmade real instead of decorative.ctx-wrapup, a saturated subagent to native compaction.GET /api/session/<id>/message), becausectx.storageis namespaced per plugin and can never see another plugin's writes;ctx.storage.get("todos/<id>")survives only as a last-resort fallback. The open-todos nudge is gated on the list rather than on the model's emoji.Since the above was written, six commits landed on this branch, all about the
todo list, its tests, and its docs — no behavior change except the first:
a83e158— the todo read described above (message log first, storage last).Before it, the port read only
ctx.storage, which is namespaced per pluginand can never see another plugin's list, so it concluded "no todos" while
work was still listed and fired false done-claim nudges.
801e22e,a355db3,5f3fb0a— docs/comments only: why the storage keycan never work cross-plugin, why the parser is a copy rather than an
import, and the same correction in
docs/known-issues-v2.md(which hadcontradicted the README) plus the test count
774→780.ef25e39— test only: one intermittent failure in 9 runs(
index.options.test.ts, expected >= 2 nudges got 1). The test pushed twoidles 10ms apart with a 0ms pattern delay and assumed the first timer fired
inside the gap; under load the second idle replaced the first pass (the
replace-don't-stack semantics above, working as designed) instead of
following it. The test now gates on the observed nudge. Product code
untouched.
f773ea6— docs only: README anddocs/known-issues-v2.mdnow nameopencode-todo-forkas the confirmed todo plugin; others unconfirmed.Explicit completion — a
task_completetool, so "done" is a signal instead of a string to pattern-match.Reasoning-tool recovery — a tool call written into reasoning is asked for again properly.
Orphan parent recovery — a parent left waiting on a subagent that will not answer, which no amount of nudging fixes.
The settle delay — see below.
Every option is now applied
FEATURE_GATED_OPTIONSis empty. Earlier pushes accepted options the port did not yet act on, and the startup line reported them asaccepted-but-inert=…. The last of those wastoolTextCheckDelayMs, and the reason it took longest is that its absence was a real bug, not a plumbing gap.v1 never judges a finished turn on the
session.idleevent. It arms a timer and runs the done/tool checks from there, becausesession.idlecan arrive while the assistant's closing text is still being written into the message history — a check that reads too early sees a half-finished turn. The v2 port judged on the idle event, using the live delta buffer, which usually has the final turn by then and is empty when the plugin loads mid-turn. The fallback in exactly that case is the history that may not have flushed.So
inspectOnIdleis now two passes:session.idle+ toolTextCheckDelayMsThe pattern pass re-reads the history — that is the point — and re-runs the structural guards rather than trusting the first verdict, because three seconds is long enough for the user to have replied. A new turn cancels a pending pass, a second idle replaces rather than stacks, and cleanup drops any pending timer.
The inert-reporting machinery is kept rather than deleted. It is the only signal a user gets that an option is being ignored, so with nothing left to report at runtime, a test asserts the invariant on the source instead: a test fails if an option joins the recognized set without being implemented.
How the port is tested
188 tests across 13 files, one per feature area, driven through the real event stream with a harness that reproduces v2's ordering. The gotchas that cost time are documented in the tests, because each one produces a failure that looks like a logic bug:
execution.started → step.started → step.ended → idlecycleThe settle-delay file is mutation-checked: removing the delay, the arming, the history re-read, either guard re-run, the cancel latch, the new-turn cancel or the cleanup each turn it red. Two of its tests were vacuous on the first pass and are only meaningful because of that check.
docs/known-issues-v2.mdand the README's option list are checked againstFEATURE_GATED_OPTIONSin both directions, so a documented-but-unimplemented option cannot pass quietly.Installation
Nothing to install first.
src/v2/index.tsimports onlynode:fs,node:osandnode:pathand defines its own{ id, setup }helper, so a local-file install needs nobun add— copy the file into~/.config/opencode/plugins/(or<project>/.opencode/plugins/) for auto-discovery, or register it in config:{ "plugins": [ { "package": "./plugins/auto-resume-v2.ts", "options": { "chunkTimeoutMs": 180000, "maxRetries": 3 } } ] }@opencode/plugin@2.0.5is a typecheck dependency, not a runtime one. Full steps and a v1→v2 migration checklist are indocs/v2/.The install guide had been telling users to
bun add @opencode/plugin@2.0.5because the beta port did import the package. This one does not, so that step is gone, along with the troubleshooting row describing theCannot find package '@opencode/plugin'failure this build cannot produce.docs/v2/installing.mdalso hadchunkTimeoutMsat 45000 in three places and a 12-test count that stopped being true a while ago.Relationship to the other open PR
The companion PR ("Stop the recovery-continue infinite loop") fixes v1's retry-counter reset and adds an OOC lock to
src/index.ts. This port is independent — a separate module, unaffected by that patch — and carries the sameexceeds the available context sizelock-out, plusmaxRetriesand acontinueTimestampsloop guard.masterwas merged into this branch (27 commits ahead). The only hand-resolved conflict was two rows in the README option table, where both sides had rewritten them; the port's version is kept, because it is the one that also covers the subagent case.The deployed v2 build running on the author's box is a build of this module; this PR is its source of truth.