From 03c493c20ecd01bf5b7d266c7d23c92f5f39ed83 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 00:48:03 +0800 Subject: [PATCH 1/3] feat: add noInterruptOnUserMessage option (human messages steer instead of pausing) When enabled, a new human message no longer pauses an active goal with stopReason 'user intervention'. The goal loop keeps running and the message steers the next continuation, matching Codex-style steering. Gates the three pause sites (chat.message, auto-continue claim guard, and the idle continuation driver); plugin-owned command/continuation messages are never interventions either way. Default remains false. Adds option docs to README, index.d.ts, and CHANGELOG, plus tests: a chat.message + idle steering test and a normalizeOptions boolean test. --- CHANGELOG.md | 2 ++ README.md | 1 + index.d.ts | 9 +++++++ src/goal-plugin.js | 15 +++++++++-- test/goal-plugin.test.js | 55 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81077c3..8eadb90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `noInterruptOnUserMessage` plugin option. When `true`, a new human message steers an active goal — the loop keeps running and the message is included in the next continuation — instead of pausing it with `stopReason: "user intervention"`. The pause-on-intervention default is unchanged. + ## 0.7.0 — 2026-08-02 - Make `/goal status` add explicit `State:` and `Completion audit:` lines without changing its existing `Active goal:` header; make `/goal list` report `active`, `paused`, or `blocked` and preserve the reason for stopped focused goals. Completion audit reporting distinguishes the evidence gate, built-in independent verifier, and custom completion auditor. diff --git a/README.md b/README.md index a56f6e9..60ca179 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,7 @@ Additional plugin-level options: - `maxRecentMessages` — how many recent session messages to scan when looking for the latest assistant turn before auto-continuing. Higher values make long, tool-heavy sessions less likely to lose the most recent assistant response. - `noProgressTurnsBeforePause` — grace window for low-output stalls. The plugin pauses only after this many consecutive stalled low-output turns rather than on the first one. - `noToolCallTurnsBeforePause` — grace window for tool-free continuation turns. The plugin pauses after this many consecutive continuation turns that produced no tool calls (anti self-chat loop). Default `2`; set the plugin option to `0` for legitimate tool-free writing/research workflows. +- `noInterruptOnUserMessage` — when `true`, a new human message no longer pauses an active goal ("user intervention"); the goal loop keeps running and the message steers the next continuation. Default `false`, which pauses for `/goal resume` as before. - `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` — thresholds at which the auto-continue prompt appends a "limits are near" warning (default `3` turns, `60000` ms, `25000` context tokens). Lower them to warn closer to the limit, or raise them to warn earlier. - `commandName` — the slash command the plugin owns (default `goal`). Set it to e.g. `objective` to drive the workflow with `/objective` instead of `/goal`; a leading slash is tolerated. Remember to register the matching command name in your OpenCode `command` config. User-facing hints (`/goal status`, `/goal resume`, …) follow the configured name. - `registerCommand` — whether the plugin installs its `command.execute.before` hook at all (default `true`). Set it to `false` if you only want the auto-continue/persistence behavior driven programmatically and don't want the plugin to own a slash command. diff --git a/index.d.ts b/index.d.ts index c15bfb7..4f3f59a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -174,6 +174,15 @@ export interface GoalPluginOptions { */ noToolCallTurnsBeforePause?: number + /** + * When `true`, a new human message does not pause an active goal: the goal + * loop keeps running and the message steers the next continuation instead of + * stopping with `stopReason: "user intervention"`. Plugin-owned command and + * continuation messages are never treated as interventions either way. + * @default false + */ + noInterruptOnUserMessage?: boolean + /** * Fraction (between 0 and 1, exclusive) of any budget (turns, duration, * or tokens) at which the plugin sends a one-time "wrap up" prompt diff --git a/src/goal-plugin.js b/src/goal-plugin.js index d8d3f6b..a5e9d9d 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -74,6 +74,7 @@ const DEFAULT_OPTIONS = { noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 2, noToolCallTurnsBeforePause: 2, + noInterruptOnUserMessage: false, budgetWrapupRatio: 0.8, warnTurnsRemaining: 3, warnDurationMsRemaining: 60 * 1000, @@ -1176,6 +1177,7 @@ function normalizeOptions(options = {}) { Number.isSafeInteger(options.noToolCallTurnsBeforePause) && options.noToolCallTurnsBeforePause >= 0 ? options.noToolCallTurnsBeforePause : DEFAULT_OPTIONS.noToolCallTurnsBeforePause, + noInterruptOnUserMessage: options.noInterruptOnUserMessage === true, budgetWrapupRatio: Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1 ? Number(options.budgetWrapupRatio) @@ -4249,7 +4251,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const newHumanMessage = refreshed.latestRealUserMessageID && refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID - if (newHumanMessage || userInterventionDetected(messages, goal)) { + if ( + !goal.options.noInterruptOnUserMessage && + (newHumanMessage || userInterventionDetected(messages, goal)) + ) { await pauseActiveGoal(sessionID, { stopReason: "user intervention", status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", @@ -4422,6 +4427,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const goal = goalStates.get(sessionID) if (!goal || goal.stopped) return + // With noInterruptOnUserMessage, a human message steers the running loop + // instead of pausing the goal for /goal resume. + if (goal.options.noInterruptOnUserMessage) return await pauseActiveGoal(sessionID, { stopReason: "user intervention", status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", @@ -5229,7 +5237,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // Latest instruction wins: if a real (non-plugin) user message arrived // since the last auto-continue, stop driving the loop and defer to the // human. They can /goal resume to hand control back to the plugin. - if (userInterventionDetected(messages, activeGoalAfterMessages)) { + if ( + !activeGoalAfterMessages.options.noInterruptOnUserMessage && + userInterventionDetected(messages, activeGoalAfterMessages) + ) { await pauseActiveGoal(sessionID, { stopReason: "user intervention", status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 2824534..c6a0eef 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -1398,6 +1398,55 @@ test("a real user message during the loop pauses auto-continue (latest instructi assert.equal(currentGoal("session-1").stopReason, "user intervention") }) +test("noInterruptOnUserMessage:true keeps the goal running and steers the loop", async () => { + const calls = [] + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ + data: [ + pluginContinuationMessage(), + message("did a step"), + userMessage("stop, do Y instead"), + message("sure"), + ], + }), + promptAsync: async (input) => { + calls.push(input) + return {} + }, + }, + } + const hooks = await GoalPlugin( + { client }, + { persistState: false, minDelayMs: 1, noInterruptOnUserMessage: true }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + // Simulate that the loop is already running. + const goal = currentGoal("session-1") + goal.turnCount = 1 + goal.lastContinueAt = Date.now() - 10 + + await hooks["chat.message"]( + { sessionID: "session-1", messageID: "msg-steer", agent: "build" }, + { + message: { id: "msg-steer", role: "user", sessionID: "session-1" }, + parts: [textPart("stop, do Y instead")], + }, + ) + assert.equal(currentGoal("session-1").stopped, false) + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + + assert.equal(currentGoal("session-1").stopped, false) + assert.equal(calls.length, 1) +}) + test("the plugin's own continuation messages do not count as user intervention", async () => { const calls = [] const client = { @@ -4955,6 +5004,12 @@ test("normalizeOptions falls back to defaults for zero, negative, and non-numeri assert.equal(result.maxRecentMessages, defaults.maxRecentMessages) }) +test("normalizeOptions defaults noInterruptOnUserMessage to false and keeps it boolean", () => { + assert.equal(normalizeOptions().noInterruptOnUserMessage, false) + assert.equal(normalizeOptions({ noInterruptOnUserMessage: true }).noInterruptOnUserMessage, true) + assert.equal(normalizeOptions({ noInterruptOnUserMessage: "yes" }).noInterruptOnUserMessage, false) +}) + test("normalizeOptions rejects budgetWrapupRatio at boundary values 0 and 1", () => { const defaults = normalizeOptions() assert.equal(normalizeOptions({ budgetWrapupRatio: 0 }).budgetWrapupRatio, defaults.budgetWrapupRatio) From 223cc87211a21195b559d4245671b04c9a064065 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 02:11:05 +0800 Subject: [PATCH 2/3] feat: add noContinueWhileChildrenActive option (defer auto-continue while subagents run) When enabled, auto-continue is deferred while the session has active child sessions (subagents, background tasks): the goal stays running but the goal loop does not prompt the orchestrator over work a child is already doing. The gate lives in claimContinuationSource, which covers both continuation paths (normal continue and budget wrapup). It checks opencode's children and status endpoints through the existing shape adapter (children/status are replay-safe read-only operations); hosts that cannot report children/status fail open and continue as before. Default remains false. Adds adapter coverage (children/status shape probing) and behavioral tests (deferral while a child is busy, continuation once children are idle, and unchanged default behavior). --- CHANGELOG.md | 1 + README.md | 1 + index.d.ts | 10 ++++ src/goal-plugin.js | 34 +++++++++++ src/opencode-session-api.js | 8 ++- test/goal-plugin.test.js | 94 +++++++++++++++++++++++++++++++ test/opencode-session-api.test.js | 26 ++++++++- 7 files changed, 172 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eadb90..3142ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add `noInterruptOnUserMessage` plugin option. When `true`, a new human message steers an active goal — the loop keeps running and the message is included in the next continuation — instead of pausing it with `stopReason: "user intervention"`. The pause-on-intervention default is unchanged. +- Add `noContinueWhileChildrenActive` plugin option. When `true`, auto-continue is deferred while the session has active child sessions (subagents, background tasks), so the goal loop does not prompt the orchestrator over work a child is already doing; the goal stays running and continues on a later idle once the children finish. Hosts that cannot report children/status fail open. ## 0.7.0 — 2026-08-02 diff --git a/README.md b/README.md index 60ca179..14a1f0b 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ Additional plugin-level options: - `noProgressTurnsBeforePause` — grace window for low-output stalls. The plugin pauses only after this many consecutive stalled low-output turns rather than on the first one. - `noToolCallTurnsBeforePause` — grace window for tool-free continuation turns. The plugin pauses after this many consecutive continuation turns that produced no tool calls (anti self-chat loop). Default `2`; set the plugin option to `0` for legitimate tool-free writing/research workflows. - `noInterruptOnUserMessage` — when `true`, a new human message no longer pauses an active goal ("user intervention"); the goal loop keeps running and the message steers the next continuation. Default `false`, which pauses for `/goal resume` as before. +- `noContinueWhileChildrenActive` — when `true`, auto-continue is deferred while the session has active child sessions (subagents, background tasks): the goal stays running but does not prompt the orchestrator until the children finish. Default `false`. Hosts that cannot report children/status fail open (continuation proceeds). - `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` — thresholds at which the auto-continue prompt appends a "limits are near" warning (default `3` turns, `60000` ms, `25000` context tokens). Lower them to warn closer to the limit, or raise them to warn earlier. - `commandName` — the slash command the plugin owns (default `goal`). Set it to e.g. `objective` to drive the workflow with `/objective` instead of `/goal`; a leading slash is tolerated. Remember to register the matching command name in your OpenCode `command` config. User-facing hints (`/goal status`, `/goal resume`, …) follow the configured name. - `registerCommand` — whether the plugin installs its `command.execute.before` hook at all (default `true`). Set it to `false` if you only want the auto-continue/persistence behavior driven programmatically and don't want the plugin to own a slash command. diff --git a/index.d.ts b/index.d.ts index 4f3f59a..6d0141f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -183,6 +183,16 @@ export interface GoalPluginOptions { */ noInterruptOnUserMessage?: boolean + /** + * When `true`, auto-continue is deferred while the session has active child + * sessions (subagents or background tasks), so the goal loop does not prompt + * the orchestrator over work a child is already doing. The goal stays + * running and the next idle event continues once the children are done. + * Hosts that cannot report children/status fail open (continuation proceeds). + * @default false + */ + noContinueWhileChildrenActive?: boolean + /** * Fraction (between 0 and 1, exclusive) of any budget (turns, duration, * or tokens) at which the plugin sends a one-time "wrap up" prompt diff --git a/src/goal-plugin.js b/src/goal-plugin.js index a5e9d9d..c4ea63a 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -75,6 +75,7 @@ const DEFAULT_OPTIONS = { noProgressTurnsBeforePause: 2, noToolCallTurnsBeforePause: 2, noInterruptOnUserMessage: false, + noContinueWhileChildrenActive: false, budgetWrapupRatio: 0.8, warnTurnsRemaining: 3, warnDurationMsRemaining: 60 * 1000, @@ -1178,6 +1179,7 @@ function normalizeOptions(options = {}) { ? options.noToolCallTurnsBeforePause : DEFAULT_OPTIONS.noToolCallTurnsBeforePause, noInterruptOnUserMessage: options.noInterruptOnUserMessage === true, + noContinueWhileChildrenActive: options.noContinueWhileChildrenActive === true, budgetWrapupRatio: Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1 ? Number(options.budgetWrapupRatio) @@ -4214,6 +4216,34 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) return true } + // With noContinueWhileChildrenActive, auto-continue is deferred while any + // child session (subagent, background task) is still active, so the goal + // loop does not prompt the orchestrator over work a child is already doing. + // Fail open: if the host cannot report children/status, continue as before. + const sessionHasActiveChildren = async (sessionID) => { + try { + const [children, status] = await Promise.all([ + sessionApi.children(sessionID), + sessionApi.status(), + ]) + const statusMap = isPlainObject(status) ? status : {} + const childrenList = Array.isArray(children) ? children : [] + return childrenList.some( + (child) => + isPlainObject(child) && + typeof child.id === "string" && + Object.hasOwn(statusMap, child.id), + ) + } catch (error) { + await logPluginError( + client, + "Failed to check child session activity; continuing without the active-children gate", + error, + ) + return false + } + } + const claimContinuationSource = async ( sessionID, goalID, @@ -4248,6 +4278,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) return null } + if (goal.options.noContinueWhileChildrenActive) { + if (await sessionHasActiveChildren(sessionID)) return null + } + const newHumanMessage = refreshed.latestRealUserMessageID && refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID diff --git a/src/opencode-session-api.js b/src/opencode-session-api.js index 4ab93c3..c8c8b1f 100644 --- a/src/opencode-session-api.js +++ b/src/opencode-session-api.js @@ -8,7 +8,7 @@ const SHAPE_ERROR_PATTERNS = [ // Only read-only operations may be retried with another argument shape. A // TypeError can be raised after a mutating SDK call has already reached the // host, so replaying create/prompt/update/delete/abort could duplicate side effects. -const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get"]) +const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get", "children", "status"]) function isArgumentShapeError(error) { if (!(error instanceof TypeError)) return false @@ -70,6 +70,12 @@ export function createOpenCodeSessionApi(client, options = {}) { { path: { id: sessionID }, query: options }, ) }, + children(sessionID) { + return invoke("children", { sessionID }, { path: { id: sessionID } }) + }, + status() { + return invoke("status", {}, { path: {} }) + }, promptAsync(sessionID, input = {}) { return invoke( "promptAsync", diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index c6a0eef..347d733 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -1447,6 +1447,88 @@ test("noInterruptOnUserMessage:true keeps the goal running and steers the loop", assert.equal(calls.length, 1) }) +test("noContinueWhileChildrenActive:true defers continuation while a child is active", async () => { + const calls = [] + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ + data: [pluginContinuationMessage(), message("did a step")], + }), + children: async () => ({ data: [{ id: "child-1", agent: "fixer" }] }), + status: async () => ({ data: { "child-1": { type: "busy" } } }), + promptAsync: async (input) => { + calls.push(input) + return {} + }, + }, + } + const hooks = await GoalPlugin( + { client }, + { + persistState: false, + minDelayMs: 1, + noContinueWhileChildrenActive: true, + // Keep the test focused on the children gate: a repeated tool-less + // assistant message would otherwise trip the no-tool-call pause. + noToolCallTurnsBeforePause: 0, + }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + // Simulate that the loop is already running. + const goal = currentGoal("session-1") + goal.turnCount = 1 + goal.lastContinueAt = Date.now() - 10 + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 0) + assert.equal(currentGoal("session-1").stopped, false) + + // Once no child is active, the next idle continues normally. + client.session.status = async () => ({ data: {} }) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1) + assert.equal(currentGoal("session-1").stopped, false) +}) + +test("active children do not block auto-continue by default", async () => { + const calls = [] + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ + data: [pluginContinuationMessage(), message("did a step")], + }), + children: async () => ({ data: [{ id: "child-1", agent: "fixer" }] }), + status: async () => ({ data: { "child-1": { type: "busy" } } }), + promptAsync: async (input) => { + calls.push(input) + return {} + }, + }, + } + const hooks = await GoalPlugin({ client }, { persistState: false, minDelayMs: 1 }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const goal = currentGoal("session-1") + goal.turnCount = 1 + goal.lastContinueAt = Date.now() - 10 + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1) +}) + test("the plugin's own continuation messages do not count as user intervention", async () => { const calls = [] const client = { @@ -5010,6 +5092,18 @@ test("normalizeOptions defaults noInterruptOnUserMessage to false and keeps it b assert.equal(normalizeOptions({ noInterruptOnUserMessage: "yes" }).noInterruptOnUserMessage, false) }) +test("normalizeOptions defaults noContinueWhileChildrenActive to false and keeps it boolean", () => { + assert.equal(normalizeOptions().noContinueWhileChildrenActive, false) + assert.equal( + normalizeOptions({ noContinueWhileChildrenActive: true }).noContinueWhileChildrenActive, + true, + ) + assert.equal( + normalizeOptions({ noContinueWhileChildrenActive: "yes" }).noContinueWhileChildrenActive, + false, + ) +}) + test("normalizeOptions rejects budgetWrapupRatio at boundary values 0 and 1", () => { const defaults = normalizeOptions() assert.equal(normalizeOptions({ budgetWrapupRatio: 0 }).budgetWrapupRatio, defaults.budgetWrapupRatio) diff --git a/test/opencode-session-api.test.js b/test/opencode-session-api.test.js index 8b25e29..02bdf91 100644 --- a/test/opencode-session-api.test.js +++ b/test/opencode-session-api.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import test from "node:test" import { createOpenCodeSessionApi } from "../src/opencode-session-api.js" -const operations = ["messages", "promptAsync", "prompt", "update", "get", "create", "delete", "abort"] +const operations = ["messages", "promptAsync", "prompt", "update", "get", "create", "delete", "abort", "children", "status"] function recordingClient(handler) { const calls = [] @@ -140,3 +140,27 @@ test("supports an explicit legacy preference without probing", async () => { await api.get("known") assert.deepEqual(host.calls[0].input, { path: { id: "known" } }) }) + +test("children and status use the shape adapter and are replay-safe", async () => { + const host = recordingClient((_operation, input) => ({ data: input })) + const api = createOpenCodeSessionApi(host.client) + assert.deepEqual(await api.children("s1"), { sessionID: "s1" }) + assert.deepEqual(await api.status(), {}) + + const legacy = recordingClient((_operation, input) => { + if (!("path" in input)) throw new TypeError("validation failed: required path") + return { data: input } + }) + const apiLegacy = createOpenCodeSessionApi(legacy.client) + await apiLegacy.children("s2") + await apiLegacy.status() + assert.deepEqual( + legacy.calls.map(({ input }) => input), + [ + { sessionID: "s2" }, + { path: { id: "s2" } }, + {}, + { path: {} }, + ], + ) +}) From 0cfe9bbca6c64cefe64c59818ab4d89a4de3803d Mon Sep 17 00:00:00 2001 From: willytop8 Date: Thu, 6 Aug 2026 01:16:34 -0500 Subject: [PATCH 3/3] fix(ci): retarget mutation anchor at the widened replay-safe set The "mutating SDK calls are never replayed" mutant pins the literal contents of REPLAY_SAFE_OPERATIONS, so widening the set to include the read-only children/status operations left the anchor matching nothing and the contract asserted before running the mutant. Retarget the anchor at the current literal; the mutant is still killed, so the guarantee that a mutating operation cannot be added to the replay-safe set is unchanged. --- scripts/mutation-contract.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs index 290dfed..92bbfed 100644 --- a/scripts/mutation-contract.mjs +++ b/scripts/mutation-contract.mjs @@ -24,8 +24,8 @@ const mutants = [ { name: "mutating SDK calls are never replayed", file: "src/opencode-session-api.js", - from: 'new Set([\"messages\", \"get\"])', - to: 'new Set([\"messages\", \"get\", \"prompt\"])', + from: 'new Set([\"messages\", \"get\", \"children\", \"status\"])', + to: 'new Set([\"messages\", \"get\", \"children\", \"status\", \"prompt\"])', test: "test/opencode-session-api.test.js", }, {