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.
|
Five commits on this branch fix the todo-nudge path and align it with the
|
Ported from f99ddd8 (auto-resume/v2-todo-message-log): stacked watchdogs each hold private counters, so the in-memory skip cannot see a sibling's prod — the session log can. Test coverage lands in src/v2/index.duplicate-prod.test.ts (index.visible-continue.test.ts no longer exists on this branch).
Ported from d74debe (auto-resume/v2-todo-message-log). Test-only delta vs the original: none — src/v2/index.duplicate-instance.test.ts and src/v2/index.inject-lock.test.ts apply as-is. Docs hunk dropped (docs/v2/backport-to-v1.md no longer exists on this branch).
…uations Ported from d756146 (auto-resume/v2-todo-message-log). Deliberate delta vs the original: the ready line keeps only the mod= instance tag — the visibleContinue= interpolation is dropped because the visibleContinue option does not exist on this branch. src/v2/index.singleton-registry.test.ts applies as-is.
…er fires into active work Ported from 2047d7c (auto-resume/v2-todo-message-log): applies cleanly, no deltas. 13 dead-stream tests pass.
…match Ported from 808fb80 (auto-resume/v2-todo-message-log): whitespace-only conflict at the ensureWatch initializer resolved to HEAD indentation. 18 unknown-tool tests pass.
…to the ban Ported from aaac082 (auto-resume/v2-todo-message-log). Deliberate delta vs the original: the inert configProbe option (interface, setup const, RECOGNISED_OPTIONS, README row) is NOT ported — it belongs to the excluded probe/docs scope. 6 rate-limit tests pass.
…ject recency Ported from 9cd4471 (auto-resume/v2-todo-message-log): applies cleanly, no deltas. 19 unknown-tool tests pass.
Ported from 7dca80d (auto-resume/v2-todo-message-log): applies cleanly, no deltas. 20 unknown-tool tests pass.
Ported from a69376a (auto-resume/v2-todo-message-log). Deliberate delta vs the original: the rich stall-text builder (buildStallContinueText) does not exist on this branch, so recover() keeps its plain continue text — only the parent-wait guard (detection + inject time) is ported. 3 parent-wait tests pass.
Ported from 8073ba1 (auto-resume/v2-todo-message-log). Deliberate deltas: kept HEAD's newer injectOnceLocked signature (required params from the mutex port) and HEAD's ownPromptTexts tracking; src/v2/index.visible- continue.test.ts restored with its 7 tests (all pass).
…channel Ported from a2d3897 (auto-resume/v2-todo-message-log): ready line keeps both tags (visibleContinue + mod= instance tag from the globalThis port). 8 visible-continue tests pass.
Ported from bf4e8c8 (auto-resume/v2-todo-message-log): applies cleanly, no deltas. 10 visible-continue tests pass.
Ported from 6e20464 (auto-resume/v2-todo-message-log): applies cleanly, no deltas. 13 stand-down tests pass.
Ported from af87ab6 (auto-resume/v2-todo-message-log): applies cleanly, no deltas. inject-lock + singleton-registry suites now write a private tmpdir logFile instead of the production default.
Ported from 2f99b9b (auto-resume/v2-todo-message-log) minus the configProbe knob row (probe scope excluded): dead-stream paragraph + silentDeadStreamMinTokens row + visibleContinue plugins-key note + known-issues dead-stream amendment.
… stub) Ported from b0b5100 (auto-resume/v2-todo-message-log): restores docs/v2/backport-to-v1.md verbatim ( HEAD had deleted it). Line-numbered research mapping v2 features onto v1's src/index.ts.
v2 suite is 233 tests across 20 files (was: 188 across 13); feature list now names the ported areas (dup suppression, mutex/singleton, quota ladder, parent-wait, visible channel). Figures from executed runs, 2026-10-05.
…out touching a live knob Ported from 1064641 (auto-resume/v2-todo-message-log): applies with neighborhood merges only. README row included (was stripped from the earlier quota/docs ports pending this decision). 23 options tests pass.
Removes SUPERSEDED.md: the fork is NOT retired and is NOT superseded. This branch is the live todo provider auto-resume reads — session list via the message log (namespaced storage fallback) backing the v2 port's done-claim verification, open-todos reminders, and task_complete blocking (upstream PR Mte90/opencode-auto-resume#37).
ses_ef72c5f3 2026-10-05: the watchdog injected a rich stall continue into a session parked on a question tool awaiting the user (186s event silence during the Q&A round-trip). The busy-stall path had no user-wait guard — only the idle path stands down. New isWaitingOnUser() covers only user-awaiting tools (question/permission/ask/confirm), so a wedged bare tool still recovers; wired at detection (checkActiveSessions) and inject time (recover). Covered by src/v2/index.user-wait.test.ts (1 repro + 2 controls).
|
Hi @Mte90 — this pushes 20 commits onto the v2 port, all fixes proven live Stall correctness
Nudge budgets
Quota
Visible channel
Hygiene / docs
Deliberately NOT included: the temporary 20s/180s stall-threshold Why a todo-plugin fork exists at all (not spelled out in the PR body)The body documents the read side (message log first, namespaced storage as What the model actually sees (nudge mockups)Sample scenario used below: 3 open todos. All texts verbatim from 1. Rich stall continue (default, 2. Bare continue ( 3. Open-todos reminder (done-claim with open todos / celebration false positive) 4. 5. Done-claim with no work description 6. Done-claim with no work detected 7. Tool call printed as text 8. Tool call left in thinking/reasoning 9. Tool loop 10. Unknown tool (with / without a close match) 11. 12. Stuck-subagent recovery |
|
if we include a fork of another plugin we have to check the license and mention it in the readme |
|
You don't have to include it. I have the fork on my github and it can be
considered standalone as it works asis with or without auto-resume.
Auto-resume just works with it if it's available. The nudges just won't
ever trigger if it's not because it won't have a todo to read. As for the
license:
Original upstream is aiev/opencode-todolist (todo-upstream-fork/package.json
repository.url: git+https://github.com/aiev/opencode-todolist.git, license:
MIT), with todo-upstream-fork/LICENSE:1 as MIT License, Copyright (c) 2026
aiev. todo-fork/package.json description confirms: “Fork of
opencode-todolist-local (MIT, aiev/opencode-todolist upstream).”
It does get referenced in the v2 documentation included.
…On Tue, Oct 6, 2026 at 3:37 AM Daniele Scasciafratte < ***@***.***> wrote:
*Mte90* left a comment (Mte90/opencode-auto-resume#37)
<#37 (comment)>
if we include a fork of another plugin we have to check the license and
mention it in the readme
—
Reply to this email directly, view it on GitHub
<#37?email_source=notifications&email_token=ABCY74FUK55CT5Z7U3AN5GL5SSONPA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMMBRGE3DKNJTGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-6011655329>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABCY74GQ6GI2G5PALGRBP3T5SSONPAVCNFSNUABGKJSXA33TNF2G64TZHMYTEMBTG4ZDOOJXG45US43TOVSTWNJVHE4DGNZSHA3TFILWAI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/ABCY74BXI5SWRBQDIWNDIWD5SSONPA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMMBRGE3DKNJTGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG>
and Android
<https://github.com/notifications/mobile/android/ABCY74HFTWXMWLTCOPJXBHT5SSONPA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMMBRGE3DKNJTGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
[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.