Skip to content

[FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626

Open

Description

@yehudacohen

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Plugins have no way to prevent the agent loop from stopping. When the loop is about to break, there is no hook that fires — so a plugin cannot inspect session state and inject a follow-up message to keep the agent working.

Why session.idle is not sufficient:

session.idle fires after the loop has already broken. A plugin receiving it can call client.session.chat to re-prompt, but this has two problems:

  1. In opencode run mode, process teardown races against the re-prompt (opencode run teardown race after session.idle can create empty continuation assistant turn ("parts": []) #15267). The continuation turn can arrive after disposal has begun.
  2. The injected message appears as a visible user message in the conversation. For use cases like workflow phase gates — where the intent is a silent "you're not done yet, keep working" — this is the wrong UX.

A hook that fires before the break avoids both problems. The re-entry happens inside the existing loop iteration with no new HTTP call, no disposal race, and the injected message uses the same internal path as a normal user turn.

Proposed solution: add a session.stopping hook to the plugin Hooks interface. A plugin sets output.stop = false and provides output.message to inject a user message and continue the loop. If no plugin modifies the output, behaviour is unchanged.

Known limitation: if a plugin unconditionally returns stop=false, the loop will run forever. Plugins are responsible for their own termination condition (e.g. a marker file, a state flag, a counter). This is consistent with how other re-entry patterns work in the plugin API.

Example use case: a workflow plugin that gates on phase completion. If the agent decides it is done but the workflow state machine says an active phase is incomplete, the plugin injects a silent continuation message. This is the same mechanism Claude Code's Stop hooks use (#12472) — if a Stop hook exits with code 2, its stderr is injected as a prompt and the agent resumes.

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    needs:complianceThis means the issue will auto-close after 2 hours.
    on Mar 8, 2026
  2. changed the title [-]feat: add session.stopping plugin hook to allow re-entering the agent loop[/-] [+][FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop[/+] on Mar 8, 2026
  3. github-actions commented on Mar 8, 2026

    @github-actions
    Contributor

    Thanks for updating the issue! It now follows the contributing guidelines and the needs:compliance label has been removed.

  4. Eitan1112 commented on Mar 10, 2026

    @Eitan1112

    +1, hitting exactly this. We use OpenCode as a headless coding agent in sandboxes. Our auto-sync plugin commits + pushes on session.idle, then polls GitLab CI to check if DB migrations passed. Problem is session.idle fires after the agent already responded, so the CI result arrives as a delayed follow-up — the user sees "done!" and then a second message about migration status.

    We dug into the code and the root cause is straightforward: hook["event"]?.() in plugin/index.ts L138 isn't awaited. The type signature already says Promise<void> but the promise is dropped.

    One minimal approach that wouldn't break existing plugins: add an event.sync hook alongside event. Same signature, but awaited. Plugins that need blocking behavior opt into it, everyone else stays on fire-and-forget event.

    // plugin/index.ts init()
    Bus.subscribeAll(async (input) => {
      const hooks = await state().then((x) => x.hooks)
      for (const hook of hooks) {
        hook["event"]?.({ event: input })          // existing — fire-and-forget
        await hook["event.sync"]?.({ event: input }) // new — awaited
      }
    })

    Happy to put up a PR if the team is open to this direction.

  5. lowlyocean commented on Apr 20, 2026

    @lowlyocean

    Can a maintainer please review this proposal?

  6. xbjpku commented on Apr 22, 2026

    @xbjpku

    Related: #23650 adds a complementary session.turn.completed event that fires after the turn ends (for post-turn plugins like sandbox review dialogs), vs session.stopping which fires before (for loop re-entry). Different use case but same lifecycle area — +9 lines, zero breaking changes.

  7. WhyILived commented on Apr 25, 2026

    @WhyILived

    pretty pls :(

  8. assigned and unassigned on Apr 26, 2026
  9. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  10. guillempuche commented on Jun 20, 2026

    @guillempuche

    Use case from a Claude Code → opencode migration: want to run pnpm check-types before the session ends and inject any type errors as context so the agent fixes them in the same session. Exact analog of the Claude Code Stop hook with exit code 2. session.idle doesn't work — it fires after the loop breaks, so re-prompting via client.session.chat appears as a visible user message rather than a silent continuation.

    Reference: SubagentStop hook in .claude/settings.json

  11. Sanderhoff-alt commented on Jul 6, 2026

    @Sanderhoff-alt

    I support this as well. Even though my main need is final state flushing rather than loop re-entry, it points to the same lifecycle gap: plugins need hooks around the point where OpenCode is about to stop using a session or run, instead of relying only on session.idle after the run has already become idle.

  12. yg-codes commented on Aug 26, 2026

    @yg-codes

    Use case from production: a verified-claims gate that can only be emulated today

    We hit a real incident this pattern would have prevented : an assistant recommended pveum aclmod /nodes/<node> -user <user>@<realm> -privs Sys.Modify — a fabricated flag (aclmod takes --roles; --privs belongs to pveum role add). It then wrote the wrong command into a repo doc, and in a later session cited its own doc as the source. The user caught it only when it 400'd on a root shell.

    The guard we wanted is exactly this issue's mechanism: on turn end, scan the final message for high-risk CLI claims, check the transcript for verification evidence (--help / man / authoritative-source fetch), and if absent, re-enter the loop once with a correction instruction.

    Working emulation (today) — a plugin, in case it's useful to others

    Since session.stopping doesn't exist, we emulate it in global plugins (~/.config/opencode/plugins/):

    • tool.execute.before → deny lying-gate shell idioms (blocking — works great)
    • event: session.idle → client.session.messages(), detect unverified claims in the last assistant message, then client.session.prompt() a correction capped at once per session (the cap is what makes it loop-safe: idle → prompt → idle → skip)

    Shortcomings that a native session.stopping hook would fix:

    1. session.idle also fires when the user just stopped typing / aborted — no way to distinguish a completed final answer from an interrupted one (we check message.error, which is approximate).
    2. The corrective prompt appears as a user turn, which pollutes the transcript and consumes a turn; a hook block-with-feedback is invisible to the user and cheaper.
    3. No way to deny the answer entirely (Claude Code Stop-hook decision: block semantics) — we can only append a correction after the answer is already visible.
    4. Race: the user may already be reading/acting on the unverified answer during the round-trip. On a production root shell, that gap is where the damage happens.

    Happy to contribute a PR if there's appetite — but the design questions (hook vs event-with-reply-channel, block semantics, recursion guard) probably need a maintainer call first.
    3. Scroll to the comment box at the bottom, paste, click Comment (green button).
    4. Verify before we move on (this is the gate): after posting, GitHub puts your comment at the end of the thread with a permalink timestamp. Paste the comment URL back here (or just the timestamp line), and I'll confirm it landed by reading the issue — then we do zai-org/GLM-5.

  13. jwitmann commented on Oct 11, 2026

    @jwitmann

    Follow-up to #44712 (closed at the V1 freeze with "port to V2"): this is a V2-native design for the same capability — a pre-terminal continuation hook for enforcement harnesses — not a line port. I checked the V2 session/hook architecture on dev first; the loop, the dispatch model, and the continuation primitive all moved, so the design is built from the current code outward. Implementation on a branch is underway with an out-of-tree harness as first consumer; posting now so contract feedback (§4), trigger placement (§5), and counter home (§7) can land before the PR. Those three sections are the review surface — everything else follows.

    Design: V2-native pre-terminal session continuation hook

    Status: proposal (experimental.* scope). Target: dev branch.

    This is a V2-native successor to #44712/#47300 — it ports semantics,
    not lines. Key locations are cited inline as file:line; paths below are
    repo-relative on dev.

    0. Metadata

    • Scope:
      • core (packages/core/src/session/runner/llm.ts + PluginHooks dep),
      • plugin types (packages/plugin/src/effect/session.ts),
      • tests, docs (~30 lines).
    • Sequencing: implement on a fork branch and validate end-to-end first
      (an out-of-tree enforcement-harness plugin as first consumer — see §11).
      This design is posted alongside the PR, not as a gate on implementation.
    • What reviewers are asked:
      • contract shape (§4),
      • trigger placement (§5),
      • counter home (§7).
      • Everything else follows.

    1. Problem

    2. V2 ground truth (constraints, not preferences)

    • Inbox law: continuation is durable admission (inbox.enqueued → delivered), steer vs queue, Safe Step Boundaries, compaction/move
      control items as delivery boundaries (specs/v2/session.md).

    • Execution ownership: the coordinator (session/execution.ts) is a
      global node; the runner (session/runner/llm.ts) is Location-scoped.
      Among termination-aware sites, only the runner has both the termination
      facts and the Location-scoped hook registry.

    • Hook model: one mutable event, sequential same-reference dispatch in
      registration order; all session-hook failure channels are never
      (plugin/hooks.ts:23-30) — veto by mutation, never by throw.

    • Events are observe-only: one encoded feed, per-connection dropping
      queues (event-stream-architecture.md). No veto rides the bus.

    • Termination path: DrainResult.Complete() → coordinator publishes
      Execution.Succeeded + releases the write-ahead claim
      (execution.ts:119-147).

      • The hook must fire before Complete() is returned — that ordering is
        the whole feature.

    3. Non-goals / what is explicitly not ported

    • No Effect-Layer dispatch code (V1's +44): PluginHooks.trigger exists;
      adding a SessionHooks key is sufficient.

    • No createUserMessage + loop-continue: continuation is inbox
      admission (§6). The V1 trick bypasses idempotency, steer priority, control
      boundaries, and step reset.

    • No in-memory per-session map with hand-rolled session.deleted pruning:
      the counter rides on session metadata (§7).

    • No throw-to-veto: session hooks can't fail; fail-closed is a default +
      catchAll (§4).

    • No bare magic cap: the cap bounds budget multiplication and is derived in
      §7, not inherited from V1's 3.

    4. Contract

    // packages/plugin/src/effect/session.ts — follows the retry-decision pattern
    // (`continue` avoided as a field name: it is a reserved word, so
    // `const { continue } = decision` is a syntax error for every voter)
    interface SessionStopping {
      readonly sessionID: Session.ID
      readonly step: number          // model steps executed this turn
      readonly stepsLimit?: number   // agent.info.steps, if configured
      readonly continuations: number // hook continuations granted so far this turn (pre-increment)
      decision: { shouldContinue: false } | { shouldContinue: true; text: string }
    }
    • Hook name: experimental.stopping (promotion criteria in §12).

    • Dispatch: registration order, last-writer-wins (the exact retry
      precedent — "later hooks see the current decision").

    • Default: { shouldContinue: false } (fail-closed — abstention preserves natural
      exit, cf. V1 default stop:true).

    • Throw: trigger wrapped in Effect.catchAll → stop (fail-closed on throw —
      required because an uncaught throw would fail the drain into
      Execution.Failed).

    • Blocking: the hook runs synchronously inside the drain — the turn waits
      while voters run. Voters must be fast or self-timeout; there is no
      framework timeout on the trigger.

    • Cost: zero when unregistered via hooks.has("session", …) (precedent:
      model-request.ts:338-339).

    • Ordering convention: a gate needing final say registers last;
      single-voter setups are fully deterministic.

      • Enforced any-stop-wins would need a short-circuit trigger variant —
        deferred, §12.

    5. Trigger sites & control flow

    Two Complete() points in advanceToStep (runner/llm.ts:61-66 entry,
    161-162 mid-work boundary), via a maybeContinueStopping helper placed
    next to settleStaleCompactions/settleStaleToolCalls (file idiom):

    1. has("session", "experimental.stopping") → false: return Complete().
    2. Read continuations counter from the already-loaded
      selected.session.metadata; if already at cap (§7) → return Complete()
      without triggering (cap exhaustion is silent by design — no voter call,
      natural exit).
    3. Build the event (default stop, continuations = pre-increment count);
      trigger (+ catchAll → stop).
    4. decision.shouldContinue === false → return Complete() untouched.
    5. shouldContinue === true → admit synthetic steer with text,
      increment the metadata counter, and do not complete: at the entry
      site fall through to activation; at the mid-work site continue the
      advanceToStep loop (the compaction precedent, llm.ts:159). The next
      iteration's normal promote path (llm.ts:163-179) picks up the steer,
      resets step, and returns Ready — no wake needed, the drain is alive.
      • Counter reset happens at fresh-drain entry (!force && !continuing,
        the llm.ts:61 site) — explicitly not at the promoted > 0
        step-reset (llm.ts:178), which fires for the hook's own steer too and
        would wipe the increment back to zero (uncapped loop). Fresh user work
        always starts a fresh drain (continuation === undefined); hook
        continuations never do — they loop in-drain — so entry-reset cannot
        self-reset. (If user prompts can also arrive as steer-delivery, this
        still holds: delivery type is irrelevant to the reset, drain freshness
        is what matters.)

    Dependency justification (for the inevitable review question):

    • The trigger must live where the termination facts and the
      Location-scoped registry coincide — that's llm.ts alone.
      • The coordinator is global and legally cannot consume PluginHooks.
      • Request/context services would need the same edge plus inbox state
        smuggled in.
    • The edge itself is minimal:
      • leaf node (PluginHooks.node deps: [] — cycle-impossible);
      • has()-gated to zero runtime cost;
      • ×5 precedent (model-request, prompt, tool, shell, permission).

    6. Continuation primitive

    Admit into the live drain (via the Session.prompt steer path,
    session/session.ts:145-177), never an in-memory loop-continue. No wake is
    needed: the trigger sites sit inside a running drain, so the next
    advanceToStep iteration promotes the admitted steer through the normal
    path (§5 step 5). (Wake + successor drain is the interrupt+resume pattern
    for a dying drain — not this case.)

    What inbox law buys for free:

    • idempotent admission (admission.reconcile);
    • steer priority over queued prompts;
    • compaction/move boundaries respected;
    • step reset automatic.

    Precedents:

    • synthetic + resume: false for no-wake admission (shell notifications);
    • steer-admit + conditional wake in interrupt+resume
      (execution.ts:153-170, including the concurrent-inbox-read safety note at
      lines 162–164).

    7. Cap & counter

    • Home: session.metadata["opencode.stopping.continuations"]
      (SessionMetadata = Record(String, Json) — durable, per-session,
      auto-pruned on delete; no pruning code, no teardown race).

    • Read: free (already-loaded session object).
      Write: read-modify-write via setMetadata (projector replaces
      metadata) — safe, single drain owns the session.
      Cost: one durable metadata.updated event per continuation, bounded by
      the cap itself.

    • Why durable, not in-memory: restart recovery resumes claimed sessions —
      an in-memory map would reset the cap exactly when a runaway loop is most
      dangerous.

    • Cap rationale: hook continuation grants a fresh agent budget (§8), so
      cap C ⇒ at most (C+1)×N model steps per user turn.

      • Proposed default C=2: one recovery + one follow-through past a single
        failure.
      • Voters read the pre-increment count from event.continuations and can
        enforce tighter policy themselves (e.g. refuse past 1 even when the
        framework would allow 2).

    8. Budget interaction

    • The hook fires after the budget-exhausted final text-only step
      (step >= steps → MAX_STEPS_PROMPT, tools disabled, llm.ts:223-244),
      carrying { step, stepsLimit } — both in hand at the trigger site.

    • Worked voter policies the fields must support:

      • (a) gate continues past limit when checks fail with cited work
        remaining;
      • (b) voter refuses continuation past 2× budget regardless.
    • Cap (re-entries) and budget (steps/drain) stay orthogonal and both
      visible; continuation.step surviving Moved/Reloaded matches the durable
      counter.

    9. Exit matrix (test rows)

    1. Compaction/move pending → controls win; hook doesn't fire (boundary
      checks precede Complete()).
    2. Moved/Reloaded → step preserved via continuation.step; hook fires
      only at true Complete().
    3. Interrupt/shutdown mid-decision → the mid-work site sits inside
      advanceToStep's Effect.uninterruptibleMask (llm.ts:71-72) but outside
      any restore() section, so trigger+admit there is uninterruptible; the
      entry site (llm.ts:61-66) runs in the drain body — verify interrupt
      behavior there at implementation (worst case: wrap the helper call).
    4. Crash recovery → durable counter survives; successor drain sees true
      count.
    5. Nested subagent sessions → per-session counters compose independently.
    6. Session remove mid-flight → metadata dies with the session; nothing to
      prune (Deleted is public but unneeded).
    7. Concurrent wakes → coordinator coalescing; single drain owner at trigger.
    8. No registered voters → has() short-circuit; byte-identical behavior.
    9. Throwing voter → catchAll → stop (fail-closed test).
    10. Multi-voter order → last-writer-wins; gate-last veto convention.

    10. Test plan

    • Harness: the living model is packages/core/test/session-runner.test.ts
      (bun:test + TestLLM from @opencode/ai/testing, real drain through
      Location node layers, voters via
      hooks.register("session", name, callback) after yield* PluginHooks.Service
      — e.g. lines 527-532, 2657-2659). New cases follow that file's idiom;
      feat(plugin): add experimental.session.stopping hook #47300's it.instance/useServerConfig helpers no longer exist on dev.
    • Core cases:
      • happy-path stop (no voters);
      • continue-admits-steer (loop continues via promote path, step reset);
      • cap exhaustion (C=2 → third stop);
      • §9 rows 1/4/6/8/9/10 as cases.
    • Gate suite: ordering, error isolation, fail-closed.
    • Standing gates:
      • bun turbo typecheck,
      • bun run lint (oxlint+ast-grep),
      • per-package tests via bun run script/test.ts.
    • Each matrix row above is an implementation task; the matrix is the
      checklist.

    11. Rollout

    12. Residual risks & deferred work

    • Short-circuit/latched-vote trigger variant (enforced any-stop-wins +
      skipping post-stop voters): deferred — unenforceable via the current
      trigger API (private callback map), and convention covers the thin
      scope.
    • Promotion criteria for dropping experimental.: two-release bake time,
      ≥1 production voter (the GATE), no dispatch-order complaints.
    • V1 SESSION_STOPPING_REENTRY_CAP=3 magic number: superseded by §7's
      derived default; recorded here so nobody "restores" it.

    13. Work breakdown

    1. Plugin types: SessionStopping + SessionHooks key (+ TUI/docs type
      surface if generated). [§4]
    2. Runner: PluginHooks dep line + maybeContinueStopping + two call
      sites. [§5]
    3. Counter: metadata read/increment/reset wiring. [§7]
    4. Event fields: step/stepsLimit plumbing from drain locals. [§8]
    5. Tests: §10 matrix, in session-runner.test.ts harness style.
    6. Dogfood: adapt the out-of-tree harness probe to the §4 contract
      (alongside, not replacing, the feat(plugin): add session.stopping hook for loop continuation #44712 shape during transition) and
      validate block/continue/cap-exhaustion against a locally-built core.
    7. Docs: plugins.mdx + example; post this note to [FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626; open the PR
      linking note + validation.
    8. Verification: typecheck, lint, tests, build green before the PR leaves
      draft.
  14. jwitmann commented on Oct 11, 2026

    @jwitmann

    Validation results for the experimental.stopping implementation (PR #54487):

    Core: 11 new scenarios in session-runner.test.ts, fail-before proven, 234/234 green. Docs mirror the behavior (plugins Stopping sections).

    Consumer: opencode-dev-framework v0.4.0 (alongside-registration, same dist works on cores with neither, either, or both hooks). Live against a locally-built core: gate-fail → grant → rework → pass, and persistent failure → exactly 2 grants → silent third stop with voters uncalled.

    Repro gotcha: bun dev runs channel=local, which isolates the fork on the near-empty opencode-local.db — subscription models read as unavailable despite identical auth.json. OPENCODE_DISABLE_CHANNEL_DB=1 fixes it. Related but separate: free-tier opencode/* inference rejects fork clients server-side ("can only be used from within OpenCode") — a client-identity gate, not auth.

    Two deviations from the posted design, both committed intentionally: step is a dedicated executedSteps counter (the drain resets step to 1 just before mid-work votes, so step - 1 was always 0), and the counter reset is gated on !continuing alone (session.resume starts forced drains, so a !force gate never reset).
    That closes out the fork-side work breakdown (§13.1–13.8 all done, dogfood receipts handed over). The remaining ball is with upstream reviewers on the three asks (contract §4, placement §5, counter §7).

  15. anandgupta42 commented on Oct 16, 2026

    @anandgupta42

    Adding a use case for the V2 work here and in #54487. My plugin gives the agent database tools. Before a turn is reported successful I want to check the SQL it produced, and if the check fails, make the agent take one more step with a short instruction. Observing idle is too late, because the execution has already succeeded.

    On 2.0.30 the runner returns needsContinuation straight from the step outcome (core/src/session/runner/llm.ts#L274) and publishes Execution.Succeeded when the drain settles (core/src/session/execution.ts#L125), so a plugin has no point at which it can ask for another step.

    For my case the number of continuations needs a bound, so a validator that always fails can't loop, and a validator should be able to just allow the stop. Happy to try experimental.stopping against our plugin.

  16. jwitmann commented on Oct 16, 2026

    @jwitmann

    Thanks for spelling this out — I think your SQL check is a good fit for what we're trying with this hook.

    You're right about the gap on 2.0.30. Once the runner decides it doesn't need a continuation (llm.ts#L274) the drain just settles and we publish Succeeded (execution.ts#L125). Watching for idle doesn't help there, the turn is already over by the time you see it.

    That's what experimental.stopping is for. It fires right before we'd return Complete(), while the turn is still open. If you set shouldContinue: true with a bit of text, that text gets admitted as a steer and the model takes another step in the same turn. If you do nothing, everything exits exactly like it does today.

    For your three points:

    The timing one should just work — the vote happens before success is reported, so a failed check can force that one more step you're missing now.

    Bounding is built in. It's capped at 2 grants per turn on a drain-owned counter, continuations tells you where you are (0, 1, 2 pre-increment). After that we stop calling voters and exit naturally. So even a validator that always fails terminates. And allowing the stop is the default — the event starts with { shouldContinue: false }, empty text also counts as abstain, so you only act on failure.

    Roughly what I imagine for you:

    "experimental.stopping": async (event, ctx) => {
      const sql = await findLastSQL(event.sessionID, ctx)
      if (!sql) return
    
      const problems = await checkSQL(sql)
      if (!problems.length) return
    
      event.decision = {
        shouldContinue: true,
        text: `The last SQL failed validation: ${problems[0]}. Fix it in one step.`,
      }
    }

    step / stepsLimit are just there so you know how far into the turn budget you are, in case you want to be more lenient late in a long turn.

    Two honest caveats:

    The event is small on purpose right now — just sessionID, step, stepsLimit, continuations, decision. It doesn't include the SQL, so you have to pull the transcript yourself to find what was produced. If that ends up being annoying, I'd like to hear what you'd actually want on there (last tool call? last assistant message?).

    And the vote gets skipped if fresh user input is already waiting — user intent wins over a validator steer — plus on an exact idle-boundary reload/move while this is still experimental. Throws get logged as stopping voter failed and count as abstain, interrupts still go through. Multiple voters run in order, last write wins, same as retry.

    If you try it, I'm curious whether 2 feels too tight for SQL fixes, and whether fetching the SQL yourself is fine or a pain. That'd tell us if the current shape is enough or needs rework before shedding the experimental. prefix.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions