Skip to content

Add OpenCode v2 port (src/v2) + OOC lock parity - #37

Open
famewolf wants to merge 149 commits into
Mte90:masterfrom
famewolf:pr20-v2port
Open

famewolf wants to merge 149 commits into
Mte90:masterfrom
famewolf:pr20-v2port

Conversation

@famewolf

@famewolf famewolf commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

[PR] OpenCode v2 port (src/v2/index.ts) on the stable @opencode/plugin API

Full disclosure — this is a port, and it was done with the assistance of AI.

src/v2/index.ts is a port of v1's behavior, not new functionality. Every recovery path it contains already exists in src/index.ts; the work was re-expressing that behavior against v2's plugin API, and keeping the two in step. The same holds for the test suite: its 188 tests exist to prove the port matches v1, not to specify anything new.

The implementation, the tests and these docs were produced with the assistance of an AI coding agent (via OpenCode) working from the v1 source, and were reviewed by the author. Flagging it plainly because a reviewer is entitled to know how the code was produced before reading it. A port this size is exactly the kind of change where "the tests pass" is weaker evidence than it looks: the tests and the code came out of the same session. Every claim in this description is checkable against the diff, and the v1 module is untouched precisely so the comparison is easy to make by hand.

One defect in v1 fell out of the port work and is not addressed here: a revert does not clear watch state, so a rewind carries one turn's watch state into the next. This PR does not touch v1, and the revert is written up separately against master with a patch. A related v1 bug — an idle nudge firing while a question awaited input — was found earlier and is #27, fixed on master in 7796b2f. The same stand-down guard is in the v2 port.

A 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.

Branch pr20-v2port (famewolf/opencode-auto-resume)
Port src/v2/index.ts, 3,514 lines
Options all 31 v1 options read and applied, plus 2 v2-only
Tests 13 v2 test files, 188 tests; 780 pass / 0 fail / 48 files repo-wide
Docs docs/known-issues-v2.md, docs/v2/installing.md, docs/v2/migration.md

Problem

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

Concern v1 v2
Plugin shape hooks object + ctx.client { id, setup }, setup returns a cleanup fn
Events event hook, { type, properties } ctx.event.subscribe() AsyncIterable, flat { type, data }
Calls ctx.client.session.* over HTTP flattened onto ctx.session.*; no list/active, so discovery goes through callSessionApi with client-side verification
Assistant text session.messages() polling accumulated from session.text.delta, with ctx.session.context() as the authority
Logging ctx.app.log no log sink exists in v2 — the port writes its own log file

Two shape differences are worth naming because they would have made a detector inert rather than wrong, which is the harder failure to notice: AssistantTool carries its name in part.name with no callID, and the tool lifecycle is session.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:

  • Unknown tool calls — v1 suggests the closest real tool name; the first v2 version silently ignored them.
  • Silent dead streams — a finished assistant message with no text part and enough output tokens is a stream that died mid-response. It is the case where every text-based check has nothing to read, so it is caught on the idle event rather than after the settle delay.
  • Premature stop — a turn that ends mid-thought without claiming completion, and activeUserWindowMs made real instead of decorative.
  • Context saturation — routes a saturated parent to magic-context ctx-wrapup, a saturated subagent to native compaction.
  • Todo list — read from the session message log (GET /api/session/<id>/message), because ctx.storage is 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 plugin
    and 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 key
    can 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 had
    contradicted 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 two
    idles 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 and docs/known-issues-v2.md now name opencode-todo-fork as the confirmed todo plugin; others unconfirmed.

  • Explicit completion — a task_complete tool, 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_OPTIONS is empty. Earlier pushes accepted options the port did not yet act on, and the startup line reported them as accepted-but-inert=…. The last of those was toolTextCheckDelayMs, 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.idle event. It arms a timer and runs the done/tool checks from there, because session.idle can 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 inspectOnIdle is now two passes:

Pass When Decides
structural on session.idle dead stream, user hand-off, user recently active
pattern + toolTextCheckDelayMs celebration, tool-call-as-text, ready-to-continue, action intent, done-claim

The 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:

  • a finished assistant message with no text part is a dead stream and fires first, so reasoning and unknown-tool fixtures need an explicit text part
  • an idled session must be removed from the harness's active set, and a reset needs a full execution.started → step.started → step.ended → idle cycle
  • a per-request re-arm derived from history cannot run before the history read — a real ordering bug, found this way

The 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.md and the README's option list are checked against FEATURE_GATED_OPTIONS in both directions, so a documented-but-unimplemented option cannot pass quietly.

Installation

Nothing to install first. src/v2/index.ts imports only node:fs, node:os and node:path and defines its own { id, setup } helper, so a local-file install needs no bun 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.5 is a typecheck dependency, not a runtime one. Full steps and a v1→v2 migration checklist are in docs/v2/.

The install guide had been telling users to bun add @opencode/plugin@2.0.5 because the beta port did import the package. This one does not, so that step is gone, along with the troubleshooting row describing the Cannot find package '@opencode/plugin' failure this build cannot produce. docs/v2/installing.md also had chunkTimeoutMs at 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 same exceeds the available context size lock-out, plus maxRetries and a continueTimestamps loop guard.

master was 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.

valentimarco and others added 8 commits August 25, 2026 12:28
- 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
@famewolf

Copy link
Copy Markdown
Contributor Author

While landing the v2 "Step interrupted" fix I checked the v1 plugin (src/index.ts, v1 API) to see whether it needed a back-port. It does not — v1 is not affected by this bug — and I wanted to record why so the PR history is clear:

  1. Different prompt model. v1 sends recovery turns with ctx.client.session.prompt, which does not interrupt a session's in-flight step when a new turn lands. v2's ctx.session.prompt does — that is the whole source of the "Step interrupted" error. v1 cannot hit this failure mode.
  2. v1 already had the guard v2 was missing. v1 carries the continue-loop hard-stop (a maxRecoveryRetries cap plus a gaveUp latch that re-arms only on a genuine user message). The v2 port had dropped that; the OOC-lock commit in this PR (0a0a961) restores it. So the one place v2 needed to match v1 is already done.
  3. The new v2 guards are net-new. The compaction guard and the inject-time live-busy guard (9a44699) address a v2-only failure mode. v1 has no compacting flag and no compaction/inject guard — not because it's less careful, but because its prompt model can't interrupt an in-flight step, so it never needed them.

Bottom line: v1 is unaffected; no back-port to v1. The v2 fix (9a44699) is v2-specific and is what this PR carries (already on this branch).

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.
@famewolf

Copy link
Copy Markdown
Contributor Author

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)
famewolf added a commit to famewolf/opencode-auto-resume that referenced this pull request Sep 28, 2026
…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.
famewolf added a commit to famewolf/opencode-auto-resume that referenced this pull request Sep 28, 2026
- add row for 875d7e4: the v2 'Step interrupted' / synthetic-continue fix
- record that 875d7e4 is a strict superset of upstream PR Mte90#20 (b5fd8aa is a
  direct ancestor), so Mte90#37 already contains everything in Mte90#20
@valentimarco

Copy link
Copy Markdown

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

famewolf added a commit to famewolf/opencode-auto-resume that referenced this pull request Sep 29, 2026
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.
@famewolf

Copy link
Copy Markdown
Contributor Author

@valentimarco the question interruption bug you mentioned is fixed — it's in 67c34ff on this branch, so the checkout you're testing already has it.

There were two independent defects behind it, both in src/v2/index.ts:

1. The stand-down guard could never fire. shouldStandDownForUser 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 is what interrupted the tool call.

It now stands down on running/pending, and additionally on interactive tools (question, permission, ask, confirm) unless they reached completed — a question that comes back error was interrupted, not answered, so it still holds the turn.

2. markIdle() destroyed the permission guard before it was read. The session.idle case calls markIdle(sid) and then inspectOnIdle(sid), and markIdle set w.permissionPending = false. 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 existing compaction one, so a permission.asked whose reply never arrives can't strand a session forever.

Two details worth knowing when reading the store, since both are easy to get wrong: ctx.session.context() returns a plain array, not { messages }. And across a live v2 session the tool-part states are completed 390, error 7, running 1 — no pending anywhere, which is what made the old condition look plausible in review.

Tests — src/v2/index.stand-down.test.ts drives the real v2 seam (ctx.event.subscribe async iterable → session.synthetic), so it asserts on real injections rather than grepping source. It carries a mandatory control that recovery does fire when nothing is pending — without it every other case passes vacuously — plus over-blocking controls (answered question, non-interactive tool erroring, permission asked-then-replied), so the fix can't silently disable auto-resume. 7 of its 13 assertions fail against the previous code. Full suite 591 pass / 0 fail, tsc --noEmit clean.

…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.
@famewolf

Copy link
Copy Markdown
Contributor Author

Small addition rather than a change: the port's revert handling documents the
v2 event names but doesn't handle them yet, and this fills that in — your
session.reverted shim is left exactly as it is.

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
switch — on a real v2 runtime a revert matches no case, so the session's watch
state (including any armed recovery) survives the rewind of the conversation
it was tracking.

Checked against a running v2 server — all three are declared event schemas with
live store handlers:

type:"session.revert.staged"      case "session.revert.staged":    … "revert", C.data.revert
type:"session.revert.committed"   case "session.revert.committed": … "revert", void
type:"session.revert.cleared"     (same shape)

staged is intermediate — the server holds a revert payload there, so the
user can still clear or commit it. committed and cleared are terminal; the
server clears the field on both.

The change

Adds the three events alongside your existing case (which stays byte-for-byte
untouched):

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
session.deleted. staged only touches, so a revert the user then abandons
doesn't needlessly tear down the watch. The commit is purely additive.

tsc --noEmit is clean. The new cases only ever fire on v2 event names, so
the shim's behaviour is unchanged and the watchdog logic is untouched.

If you'd rather keep this PR to the port itself, happy to split it into its
own PR — it's one self-contained commit with no interaction with the rest of
the port.

@famewolf

Copy link
Copy Markdown
Contributor Author

One more v1-parity item for this port.

The v1 plugin detects subagent sessions and excludes them from recovery. Two
places in src/index.ts on master:

  • handleEvent (~L2675):
    w.isSubagent = typeof parentID === "string" && parentID.length > 0
  • startTimer (L2247): if (w.isSubagent) continue — a subagent is never the
    target of the stall "continue" prompt.

The v2 port dropped both. Consequence: a child session (spawned as a subagent)
is indistinguishable from the parent, and both injection paths here —
injectOnce() and tryAbortAndResume() — can target it. That matters because a
child never reads the parent's AGENTS.md, so a "stand down on injected
continues" rule in the parent gives it no protection once the prompt arrives as
a task turn in the child, mid-task.

I have the port-side fix on my fork, branch local/v2-subagent-guard:
famewolf/opencode-auto-resume@pr20-v2port...local/v2-subagent-guard
(three commits, all additive on top of this PR's head). It re-adds the
parentID-based skip at the top of both injection paths, tracks open shells from
the shell.created/shell.exited registry events so a busy parent isn't
force-resumed, and aligns the stall window with v1
(DEFAULT_CHUNK_TIMEOUT_MS 45s → 180s — proposed for v1 master as a separate,
independent PR).

Happy to either fold this into PR #37 as a follow-up commit or open it as its
own PR stacked on pr20-v2port — whichever you prefer.

famewolf added a commit to famewolf/opencode-auto-resume that referenced this pull request Sep 30, 2026
…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.
famewolf and others added 19 commits October 11, 2026 19:24
…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
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.
@famewolf

Copy link
Copy Markdown
Contributor Author

Fix for the test failure on 1667832 — follow-up commit b27cda5 (test-only, CI green)

The 1/1026 failure (v2: silent tool completions defeat the exact-duplicate skip > a tool that ran silently between prods re-arms the stall prod — expected ≥ 2 prods, got 1) was a racy test, not a production bug.

Root cause: the test scheduled its session.tool.success at t+300ms, but with 20ms stall ticks the default lifetime cap (9 continues) burns in ~230ms and stands the session down before the event arrives — so no second prod could fire regardless of what the duplicate-skip logic did. The skip defeat itself was always correct (completedSinceProd increments on session.tool.success in src/v2/index.ts:6397, per-stall ladder restarts via succeededSinceProd at :3245); I verified that with isolation probes where the session was still alive. It passed or failed by machine speed, which is why it went red on this run.

Fix in src/v2/index.dup-progress.test.ts:

  • tool event 300ms → 100ms (mid-ladder, before the cap burn)
  • lifetimeMaxContinues: 99 in the test options, so the test exercises the skip gate instead of racing the lifetime gate

Deliberately did not touch lifetime-cap semantics — clearing it on tool completions would gut real loop protection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants