You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626
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:
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.
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
+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.
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.
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.
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.
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:
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).
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.
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.
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.
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.
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.
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.
Enforcement harnesses (e.g. a typecheck/test GATE) need a pre-terminal
veto: at natural turn end, run checks, then either end the turn or
continue it with a steer.
The available fallback — session.idle re-prompt — is post-terminal, which
breaks it four ways:
Bottom line: something like session.stopping is still load-bearing.
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.
The hook must fire beforeComplete() 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)interfaceSessionStopping{readonlysessionID: Session.IDreadonlystep: number// model steps executed this turnreadonlystepsLimit?: number// agent.info.steps, if configuredreadonlycontinuations: 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").
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):
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).
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.
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.
Moved/Reloaded → step preserved via continuation.step; hook fires
only at true Complete().
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).
Crash recovery → durable counter survives; successor drain sees true
count.
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);
Validation is dogfooded: an out-of-tree enforcement-harness plugin (which
already probes for a hook of this shape) is adapted to the §4 contract
and exercised against a locally-built core running a real GATE scenario
(block + continue paths, cap exhaustion).
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
Plugin types: SessionStopping + SessionHooks key (+ TUI/docs type
surface if generated). [§4]
Runner: PluginHooks dep line + maybeContinueStopping + two call
sites. [§5]
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).
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.
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)=>{constsql=awaitfindLastSQL(event.sessionID,ctx)if(!sql)returnconstproblems=awaitcheckSQL(sql)if(!problems.length)returnevent.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.
Activity
[-]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[/+]github-actions commentedon Mar 8, 2026
Thanks for updating the issue! It now follows the contributing guidelines and the
needs:compliancelabel has been removed.Eitan1112 commentedon Mar 10, 2026
+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 issession.idlefires 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"]?.()inplugin/index.tsL138 isn't awaited. The type signature already saysPromise<void>but the promise is dropped.One minimal approach that wouldn't break existing plugins: add an
event.synchook alongsideevent. Same signature, but awaited. Plugins that need blocking behavior opt into it, everyone else stays on fire-and-forgetevent.Happy to put up a PR if the team is open to this direction.
lowlyocean commentedon Apr 20, 2026
Can a maintainer please review this proposal?
xbjpku commentedon Apr 22, 2026
Related: #23650 adds a complementary
session.turn.completedevent that fires after the turn ends (for post-turn plugins like sandbox review dialogs), vssession.stoppingwhich fires before (for loop re-entry). Different use case but same lifecycle area — +9 lines, zero breaking changes.WhyILived commentedon Apr 25, 2026
pretty pls :(
feat(runtimes): add OpenCode (+OpenRouter) and Kimi Code CLI runtimes (…
guillempuche commentedon Jun 20, 2026
Use case from a Claude Code → opencode migration: want to run
pnpm check-typesbefore 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.idledoesn't work — it fires after the loop breaks, so re-prompting viaclient.session.chatappears as a visible user message rather than a silent continuation.Reference:
SubagentStophook in.claude/settings.jsonSanderhoff-alt commentedon Jul 6, 2026
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.idleafter the run has already become idle.fix(clients): correct OpenCode hook emitter's yadgar CLI contract
feat(install): OpenCode hook port v5.166.3 (17 commits) (#4)
feat(install): OpenCode hook port v5.166.3 (17 commits) (#4)
feat(install): OpenCode hook port v5.166.3 (17 commits) (#4)
feat(install): OpenCode hook port v5.166.3 (17 commits) (#4)
yg-codes commentedon Aug 26, 2026
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 (aclmodtakes--roles;--privsbelongs topveum 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.stoppingdoesn'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, thenclient.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.stoppinghook would fix:session.idlealso fires when the user just stopped typing / aborted — no way to distinguish a completed final answer from an interrupted one (we checkmessage.error, which is approximate).decision: blocksemantics) — we can only append a correction after the answer is already visible.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.
jwitmann commentedon Oct 11, 2026
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:devbranch.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
packages/core/src/session/runner/llm.ts+PluginHooksdep),packages/plugin/src/effect/session.ts),(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.
with code but no consumer proof; this arrives with both.
1. Problem
Enforcement harnesses (e.g. a typecheck/test GATE) need a pre-terminal
veto: at natural turn end, run checks, then either end the turn or
continue it with a steer.
The available fallback —
session.idlere-prompt — is post-terminal, whichbreaks it four ways:
Complementary threads don't cover the gap:
turn.completed) is observe-only;Stopfor lack of a V2 loop hook.The V1 answers are closed unported after the V1 freeze:
Bottom line: something like
session.stoppingis still load-bearing.2. V2 ground truth (constraints, not preferences)
Inbox law: continuation is durable admission (
inbox.enqueued → delivered),steervsqueue, Safe Step Boundaries, compaction/movecontrol items as delivery boundaries (
specs/v2/session.md).Execution ownership: the coordinator (
session/execution.ts) is aglobal 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 publishesExecution.Succeeded+ releases the write-ahead claim(
execution.ts:119-147).Complete()is returned — that ordering isthe whole feature.
3. Non-goals / what is explicitly not ported
No Effect-Layer dispatch code (V1's +44):
PluginHooks.triggerexists;adding a
SessionHookskey is sufficient.No
createUserMessage+ loop-continue: continuation is inboxadmission (§6). The V1 trick bypasses idempotency, steer priority, control
boundaries, and step reset.
No in-memory per-session map with hand-rolled
session.deletedpruning: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
Hook name:
experimental.stopping(promotion criteria in §12).Dispatch: registration order, last-writer-wins (the exact
retryprecedent — "later hooks see the current decision").
Default:
{ shouldContinue: false }(fail-closed — abstention preserves naturalexit, 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.
triggervariant —deferred, §12.
5. Trigger sites & control flow
Two
Complete()points inadvanceToStep(runner/llm.ts:61-66entry,161-162mid-work boundary), via amaybeContinueStoppinghelper placednext to
settleStaleCompactions/settleStaleToolCalls(file idiom):has("session", "experimental.stopping")→ false: returnComplete().selected.session.metadata; if already at cap (§7) → returnComplete()without triggering (cap exhaustion is silent by design — no voter call,
natural exit).
continuations= pre-increment count);trigger(+catchAll→ stop).decision.shouldContinue === false→ returnComplete()untouched.shouldContinue === true→ admit synthetic steer withtext,increment the metadata counter, and do not complete: at the entry
site fall through to activation; at the mid-work site
continuetheadvanceToSteploop (the compaction precedent,llm.ts:159). The nextiteration's normal promote path (
llm.ts:163-179) picks up the steer,resets
step, and returnsReady— no wake needed, the drain is alive.!force && !continuing,the
llm.ts:61site) — explicitly not at thepromoted > 0step-reset (
llm.ts:178), which fires for the hook's own steer too andwould wipe the increment back to zero (uncapped loop). Fresh user work
always starts a fresh drain (
continuation === undefined); hookcontinuations never do — they loop in-drain — so entry-reset cannot
self-reset. (If user prompts can also arrive as
steer-delivery, thisstill holds: delivery type is irrelevant to the reset, drain freshness
is what matters.)
Dependency justification (for the inevitable review question):
Location-scoped registry coincide — that's
llm.tsalone.PluginHooks.smuggled in.
PluginHooks.nodedeps: []— cycle-impossible);has()-gated to zero runtime cost;6. Continuation primitive
Admit into the live drain (via the
Session.promptsteer path,session/session.ts:145-177), never an in-memory loop-continue. No wake isneeded: the trigger sites sit inside a running drain, so the next
advanceToStepiteration promotes the admitted steer through the normalpath (§5 step 5). (Wake + successor drain is the interrupt+resume pattern
for a dying drain — not this case.)
What inbox law buys for free:
admission.reconcile);stepreset automatic.Precedents:
synthetic+resume: falsefor no-wake admission (shell notifications);(
execution.ts:153-170, including the concurrent-inbox-read safety note atlines 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 replacesmetadata) — safe, single drain owns the session.
Cost: one durable
metadata.updatedevent per continuation, bounded bythe 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.
failure.
event.continuationsand canenforce 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:
remaining;
Cap (re-entries) and budget (steps/drain) stay orthogonal and both
visible;
continuation.stepsurviving Moved/Reloaded matches the durablecounter.
9. Exit matrix (test rows)
checks precede
Complete()).Moved/Reloaded→ step preserved viacontinuation.step; hook firesonly at true
Complete().advanceToStep'sEffect.uninterruptibleMask(llm.ts:71-72) but outsideany
restore()section, so trigger+admit there is uninterruptible; theentry site (
llm.ts:61-66) runs in the drain body — verify interruptbehavior there at implementation (worst case: wrap the helper call).
count.
removemid-flight → metadata dies with the session; nothing toprune (
Deletedis public but unneeded).has()short-circuit; byte-identical behavior.catchAll→ stop (fail-closed test).10. Test plan
packages/core/test/session-runner.test.ts(bun:test +
TestLLMfrom@opencode/ai/testing, real drain throughLocation node layers, voters via
hooks.register("session", name, callback)afteryield* 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/useServerConfighelpers no longer exist ondev.bun turbo typecheck,bun run lint(oxlint+ast-grep),bun run script/test.ts.checklist.
11. Rollout
experimental.*prefix; docs (~30 lines:plugins.mdx+typecheck-fail → continue example).
already probes for a hook of this shape) is adapted to the §4 contract
and exercised against a locally-built core running a real GATE scenario
(block + continue paths, cap exhaustion).
[FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626 → open the PR linking both.
feat(plugin): add experimental.session.stopping hook #47300 precedent), but they run in parallel with code, not before it.
12. Residual risks & deferred work
triggervariant (enforced any-stop-wins +skipping post-stop voters): deferred — unenforceable via the current
triggerAPI (private callback map), and convention covers the thinscope.
experimental.: two-release bake time,≥1 production voter (the GATE), no dispatch-order complaints.
SESSION_STOPPING_REENTRY_CAP=3magic number: superseded by §7'sderived default; recorded here so nobody "restores" it.
13. Work breakdown
SessionStopping+SessionHookskey (+ TUI/docs typesurface if generated). [§4]
PluginHooksdep line +maybeContinueStopping+ two callsites. [§5]
step/stepsLimitplumbing from drain locals. [§8]session-runner.test.tsharness style.(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.
plugins.mdx+ example; post this note to [FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626; open the PRlinking note + validation.
draft.
jwitmann commentedon Oct 11, 2026
Validation results for the
experimental.stoppingimplementation (PR #54487):Core: 11 new scenarios in
session-runner.test.ts, fail-before proven, 234/234 green. Docs mirror the behavior (pluginsStopping sections).Consumer:
opencode-dev-frameworkv0.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 devrunschannel=local, which isolates the fork on the near-emptyopencode-local.db— subscription models read as unavailable despite identicalauth.json.OPENCODE_DISABLE_CHANNEL_DB=1fixes it. Related but separate: free-tieropencode/*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:
stepis a dedicatedexecutedStepscounter (the drain resetsstepto 1 just before mid-work votes, sostep - 1was always 0), and the counter reset is gated on!continuingalone (session.resumestarts forced drains, so a!forcegate 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).
anandgupta42 commentedon Oct 16, 2026
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
needsContinuationstraight from the step outcome (core/src/session/runner/llm.ts#L274) and publishesExecution.Succeededwhen 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.stoppingagainst our plugin.jwitmann commentedon Oct 16, 2026
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 publishSucceeded(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.stoppingis for. It fires right before we'd returnComplete(), while the turn is still open. If you setshouldContinue: truewith 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,
continuationstells 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:
step/stepsLimitare 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 failedand count as abstain, interrupts still go through. Multiple voters run in order, last write wins, same asretry.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.docs(roadmap): record the 1.0 forge-evidence, review, benchmark and O…
docs(roadmap): refresh the open items after v0.22.0 (#781)