diff --git a/CHANGELOG.md b/CHANGELOG.md index 81077c3..3142ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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 - 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..14a1f0b 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,8 @@ 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. +- `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 c15bfb7..6d0141f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -174,6 +174,25 @@ 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 + + /** + * 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/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", }, { diff --git a/src/goal-plugin.js b/src/goal-plugin.js index d8d3f6b..c4ea63a 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -74,6 +74,8 @@ const DEFAULT_OPTIONS = { noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 2, noToolCallTurnsBeforePause: 2, + noInterruptOnUserMessage: false, + noContinueWhileChildrenActive: false, budgetWrapupRatio: 0.8, warnTurnsRemaining: 3, warnDurationMsRemaining: 60 * 1000, @@ -1176,6 +1178,8 @@ function normalizeOptions(options = {}) { Number.isSafeInteger(options.noToolCallTurnsBeforePause) && options.noToolCallTurnsBeforePause >= 0 ? 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) @@ -4212,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, @@ -4246,10 +4278,17 @@ 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 - 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 +4461,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 +5271,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/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 2824534..347d733 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -1398,6 +1398,137 @@ 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("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 = { @@ -4955,6 +5086,24 @@ 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 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: {} }, + ], + ) +})