Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
8 changes: 7 additions & 1 deletion src/opencode-session-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
149 changes: 149 additions & 0 deletions test/goal-plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion test/opencode-session-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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: {} },
],
)
})