opencode-goal-plugin 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/index.d.ts +19 -0
- package/package.json +3 -3
- package/src/goal-plugin.js +333 -8
- package/src/opencode-session-api.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.1 — 2026-08-07
|
|
6
|
+
|
|
7
|
+
- Fix goal auto-continue stalling after session compaction: a continuation
|
|
8
|
+
claim for a pre-compaction source turn could match the still-visible tail
|
|
9
|
+
assistant message after compaction and suppress the post-compaction
|
|
10
|
+
continuation until the user nudged the goal. The claim is now invalidated on
|
|
11
|
+
`session.compacted`, so the loop resumes on the next idle. Contributed by
|
|
12
|
+
[@harryzhou2000](https://github.com/harryzhou2000) in
|
|
13
|
+
[#58](https://github.com/willytop8/OpenCode-goal-plugin/pull/58).
|
|
14
|
+
- Update the bundled `zod` dependency from 4.1.8 to 4.4.3.
|
|
15
|
+
|
|
16
|
+
## 0.8.0 — 2026-08-06
|
|
17
|
+
|
|
18
|
+
Both new options in this release were contributed by
|
|
19
|
+
[@harryzhou2000](https://github.com/harryzhou2000) in
|
|
20
|
+
[#53](https://github.com/willytop8/OpenCode-goal-plugin/pull/53).
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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. A child counts as active only while the host reports a non-idle status for it. Each deferral episode records a `deferred` history event and a status line so `/goal status` distinguishes "waiting on a subagent" from a hung loop. A deferred goal is re-driven by the child's own idle event, since a parent that is already idle emits no event of its own while a child runs. Hosts that cannot report children/status, and sessions with more concurrent children than the plugin can track, fail open and log once per plugin instance.
|
|
24
|
+
|
|
5
25
|
## 0.7.0 — 2026-08-02
|
|
6
26
|
|
|
7
27
|
- 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.
|
package/README.md
CHANGED
|
@@ -329,6 +329,8 @@ Additional plugin-level options:
|
|
|
329
329
|
- `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.
|
|
330
330
|
- `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.
|
|
331
331
|
- `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.
|
|
332
|
+
- `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. Because typing a message no longer stops the loop, `/goal pause` and `/goal stop` become the way to halt it. Default `false`, which pauses for `/goal resume` as before.
|
|
333
|
+
- `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. A child counts as active only while the host reports a non-idle status for it, and each deferral is reported in `/goal status` and the lifecycle history so a waiting goal is never mistaken for a hung one. Default `false`. Enabling it adds a `children` and a `status` call to each idle the goal loop evaluates. The gate fails open — continuation proceeds — for hosts that cannot report children/status, for sessions with more concurrent children than the plugin can track, and for children that run goals of their own. Note that the gate relies on the child's own idle event to resume, so a host that never emits one leaves the goal waiting; `/goal status` reports the deferral in that case.
|
|
332
334
|
- `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.
|
|
333
335
|
- `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.
|
|
334
336
|
- `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.
|
package/index.d.ts
CHANGED
|
@@ -174,6 +174,25 @@ export interface GoalPluginOptions {
|
|
|
174
174
|
*/
|
|
175
175
|
noToolCallTurnsBeforePause?: number
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* When `true`, a new human message does not pause an active goal: the goal
|
|
179
|
+
* loop keeps running and the message steers the next continuation instead of
|
|
180
|
+
* stopping with `stopReason: "user intervention"`. Plugin-owned command and
|
|
181
|
+
* continuation messages are never treated as interventions either way.
|
|
182
|
+
* @default false
|
|
183
|
+
*/
|
|
184
|
+
noInterruptOnUserMessage?: boolean
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* When `true`, auto-continue is deferred while the session has active child
|
|
188
|
+
* sessions (subagents or background tasks), so the goal loop does not prompt
|
|
189
|
+
* the orchestrator over work a child is already doing. The goal stays
|
|
190
|
+
* running and the next idle event continues once the children are done.
|
|
191
|
+
* Hosts that cannot report children/status fail open (continuation proceeds).
|
|
192
|
+
* @default false
|
|
193
|
+
*/
|
|
194
|
+
noContinueWhileChildrenActive?: boolean
|
|
195
|
+
|
|
177
196
|
/**
|
|
178
197
|
* Fraction (between 0 and 1, exclusive) of any budget (turns, duration,
|
|
179
198
|
* or tokens) at which the plugin sends a one-time "wrap up" prompt
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Durable, guarded goal workflows for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
"name": "willytop8"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"typescript": "
|
|
75
|
+
"typescript": "7.0.2"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"zod": "4.
|
|
78
|
+
"zod": "4.4.3"
|
|
79
79
|
}
|
|
80
80
|
}
|
package/src/goal-plugin.js
CHANGED
|
@@ -40,6 +40,9 @@ function legacyHomeStateFilePath(env = process.env) {
|
|
|
40
40
|
return join(homeBase(env), ".opencode-goal-plugin", "state.json")
|
|
41
41
|
}
|
|
42
42
|
const MAX_HISTORY_ENTRIES = 20
|
|
43
|
+
// Marks a plugin-synthesized parent wake so the receiving pass knows it is
|
|
44
|
+
// re-examining an assistant turn that has already been scored.
|
|
45
|
+
const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake")
|
|
43
46
|
const MAX_CHECKPOINTS = 5
|
|
44
47
|
const CHECKPOINT_CHAR_LIMIT = 280
|
|
45
48
|
const MAX_GOAL_OBJECTIVE_LENGTH = 4000
|
|
@@ -74,6 +77,8 @@ const DEFAULT_OPTIONS = {
|
|
|
74
77
|
noProgressTokenThreshold: 50,
|
|
75
78
|
noProgressTurnsBeforePause: 2,
|
|
76
79
|
noToolCallTurnsBeforePause: 2,
|
|
80
|
+
noInterruptOnUserMessage: false,
|
|
81
|
+
noContinueWhileChildrenActive: false,
|
|
77
82
|
budgetWrapupRatio: 0.8,
|
|
78
83
|
warnTurnsRemaining: 3,
|
|
79
84
|
warnDurationMsRemaining: 60 * 1000,
|
|
@@ -1176,6 +1181,8 @@ function normalizeOptions(options = {}) {
|
|
|
1176
1181
|
Number.isSafeInteger(options.noToolCallTurnsBeforePause) && options.noToolCallTurnsBeforePause >= 0
|
|
1177
1182
|
? options.noToolCallTurnsBeforePause
|
|
1178
1183
|
: DEFAULT_OPTIONS.noToolCallTurnsBeforePause,
|
|
1184
|
+
noInterruptOnUserMessage: options.noInterruptOnUserMessage === true,
|
|
1185
|
+
noContinueWhileChildrenActive: options.noContinueWhileChildrenActive === true,
|
|
1179
1186
|
budgetWrapupRatio:
|
|
1180
1187
|
Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1
|
|
1181
1188
|
? Number(options.budgetWrapupRatio)
|
|
@@ -4194,6 +4201,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4194
4201
|
if (!goal) return false
|
|
4195
4202
|
if (goal.stopped && goal.stopReason === reason) return false
|
|
4196
4203
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
4204
|
+
// A goal stopping while deferred must release its watched children, or the
|
|
4205
|
+
// watch outlives the goal and a later child idle re-drives a dead loop.
|
|
4206
|
+
clearDeferredChildren(sessionID)
|
|
4207
|
+
childDeferralNotices.delete(childDeferralKey(sessionID, goal))
|
|
4197
4208
|
goal.stopped = true
|
|
4198
4209
|
goal.stopReason = reason
|
|
4199
4210
|
goal.lastStatus = `${status} Run /${commandName} resume to continue.`
|
|
@@ -4212,6 +4223,176 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4212
4223
|
return true
|
|
4213
4224
|
}
|
|
4214
4225
|
|
|
4226
|
+
// A child counts as active only when the host reports a non-idle status for
|
|
4227
|
+
// it. OpenCode drops idle sessions from the `/session/status` map, so bare
|
|
4228
|
+
// key presence happens to work today, but the SDK response type is
|
|
4229
|
+
// `{[id: string]: SessionStatus}` and `SessionStatus` includes `{type:
|
|
4230
|
+
// "idle"}`. A host that reports idle children explicitly would otherwise
|
|
4231
|
+
// gate every continuation forever and stall the goal with no diagnostics.
|
|
4232
|
+
// Unknown/unparseable status shapes stay "active" so the gate errs toward
|
|
4233
|
+
// deferring rather than double-driving a session a child is working in.
|
|
4234
|
+
const childStatusIsActive = (statusMap, childID) => {
|
|
4235
|
+
if (!Object.hasOwn(statusMap, childID)) return false
|
|
4236
|
+
const status = statusMap[childID]
|
|
4237
|
+
return !(isPlainObject(status) && status.type === "idle")
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
// Hosts that cannot report children/status fail open, but the failure is a
|
|
4241
|
+
// property of the host, not of a single turn: logging it on every
|
|
4242
|
+
// continuation attempt would add one identical error per goal turn.
|
|
4243
|
+
const childActivityProbeFailuresLogged = new Set()
|
|
4244
|
+
const logChildActivityProbeFailure = (kind, message, error) => {
|
|
4245
|
+
if (childActivityProbeFailuresLogged.has(kind)) return Promise.resolve()
|
|
4246
|
+
childActivityProbeFailuresLogged.add(kind)
|
|
4247
|
+
return logPluginError(
|
|
4248
|
+
client,
|
|
4249
|
+
`${message} (further ${kind} failures are suppressed for this plugin instance)`,
|
|
4250
|
+
error,
|
|
4251
|
+
)
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
// With noContinueWhileChildrenActive, auto-continue is deferred while any
|
|
4255
|
+
// child session (subagent, background task) is still active, so the goal
|
|
4256
|
+
// loop does not prompt the orchestrator over work a child is already doing.
|
|
4257
|
+
// Fail open: if the host cannot report children/status, continue as before.
|
|
4258
|
+
const activeChildSessionIDs = async (sessionID) => {
|
|
4259
|
+
try {
|
|
4260
|
+
const [children, status] = await Promise.all([
|
|
4261
|
+
sessionApi.children(sessionID),
|
|
4262
|
+
sessionApi.status(),
|
|
4263
|
+
])
|
|
4264
|
+
// A live opencode SDK does not throw on an argument-shape mismatch; it
|
|
4265
|
+
// resolves with `{error, request, response}` and no `data`. Treating that
|
|
4266
|
+
// silently as "no children" would turn the whole gate into a no-op with
|
|
4267
|
+
// no diagnostic, so an unusable payload takes the same logged fail-open
|
|
4268
|
+
// path as a thrown error.
|
|
4269
|
+
if (!Array.isArray(children) || !isPlainObject(status)) {
|
|
4270
|
+
await logChildActivityProbeFailure(
|
|
4271
|
+
"payload",
|
|
4272
|
+
"Child session activity probe returned an unusable payload; continuing without the active-children gate",
|
|
4273
|
+
new Error(
|
|
4274
|
+
`children=${Array.isArray(children) ? "array" : typeof children}, status=${isPlainObject(status) ? "object" : typeof status}`,
|
|
4275
|
+
),
|
|
4276
|
+
)
|
|
4277
|
+
return []
|
|
4278
|
+
}
|
|
4279
|
+
return children
|
|
4280
|
+
.filter(
|
|
4281
|
+
(child) =>
|
|
4282
|
+
isPlainObject(child) &&
|
|
4283
|
+
typeof child.id === "string" &&
|
|
4284
|
+
childStatusIsActive(status, child.id),
|
|
4285
|
+
)
|
|
4286
|
+
.map((child) => child.id)
|
|
4287
|
+
} catch (error) {
|
|
4288
|
+
await logChildActivityProbeFailure(
|
|
4289
|
+
"probe",
|
|
4290
|
+
"Failed to check child session activity; continuing without the active-children gate",
|
|
4291
|
+
error,
|
|
4292
|
+
)
|
|
4293
|
+
return []
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// Deferral is only announced on the transition into and out of the gated
|
|
4298
|
+
// state. Without this the goal reports itself as running while doing nothing
|
|
4299
|
+
// at all, which is indistinguishable from a hang in `/goal status`.
|
|
4300
|
+
const childDeferralNotices = new Set()
|
|
4301
|
+
const childDeferralKey = (sessionID, goal) =>
|
|
4302
|
+
`${sessionID}\u0000${goal.goalId}\u0000${goal.runId}`
|
|
4303
|
+
|
|
4304
|
+
// Idle events are session-scoped and a child's completion is delivered only
|
|
4305
|
+
// on the child's own session: a parent that is already idle emits nothing at
|
|
4306
|
+
// all while a child runs and finishes (verified against a live opencode
|
|
4307
|
+
// server). Because the continuation driver is purely event-driven, a goal
|
|
4308
|
+
// deferred behind a child would never be retried. Remember the children we
|
|
4309
|
+
// deferred on so their idle event can re-drive the parent exactly once.
|
|
4310
|
+
// Entries carry the goal identity, not just the parent session: `cleanupGoal`
|
|
4311
|
+
// runs on clear/replace/complete from many call sites, so rather than hooking
|
|
4312
|
+
// every one of them the wake path re-validates that the goal which deferred is
|
|
4313
|
+
// still the goal in focus. A stale entry is dropped instead of driving a
|
|
4314
|
+
// continuation for a goal that never deferred.
|
|
4315
|
+
const MAX_DEFERRED_CHILD_WATCH = 256
|
|
4316
|
+
const deferredChildWatch = new Map()
|
|
4317
|
+
// Monotonic marker for idle events seen from sessions that hold no goal. The
|
|
4318
|
+
// probe is asynchronous, so a child can go idle between the status snapshot
|
|
4319
|
+
// and the watch being armed: its event arrives with nothing armed, is
|
|
4320
|
+
// dropped, and the watch is then set on a session that will never emit again.
|
|
4321
|
+
// Recording the sequence at which each child was last seen idle lets the gate
|
|
4322
|
+
// notice that and continue instead of waiting forever.
|
|
4323
|
+
// Guards the synthesized parent wake below against re-entering itself. Keyed
|
|
4324
|
+
// by parent session: the wake is awaited across several SDK round-trips, and
|
|
4325
|
+
// a single shared counter would drop every other parent's wake arriving in
|
|
4326
|
+
// that window — a permanent strand, silently, in an unrelated goal.
|
|
4327
|
+
const childWakeInFlight = new Set()
|
|
4328
|
+
let idleEventSequence = 0
|
|
4329
|
+
const childIdleSequence = new Map()
|
|
4330
|
+
const recordChildIdle = (childSessionID) => {
|
|
4331
|
+
if (!childSessionID) return
|
|
4332
|
+
idleEventSequence += 1
|
|
4333
|
+
childIdleSequence.set(childSessionID, idleEventSequence)
|
|
4334
|
+
while (childIdleSequence.size > MAX_DEFERRED_CHILD_WATCH) {
|
|
4335
|
+
childIdleSequence.delete(childIdleSequence.keys().next().value)
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
const idledSince = (childSessionID, sequence) =>
|
|
4339
|
+
(childIdleSequence.get(childSessionID) ?? 0) > sequence
|
|
4340
|
+
// Returns false when the children cannot all be tracked. Deferring without a
|
|
4341
|
+
// complete watch would strand the goal the moment an untracked child is the
|
|
4342
|
+
// one that finishes, so the caller continues instead. Capacity is never
|
|
4343
|
+
// reclaimed by evicting a live entry: that is the same silent strand seen
|
|
4344
|
+
// from the other direction.
|
|
4345
|
+
const watchDeferredChildren = (sessionID, goal, childIDs) => {
|
|
4346
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4347
|
+
if (watched.sessionID === sessionID && !childIDs.includes(childID)) {
|
|
4348
|
+
deferredChildWatch.delete(childID)
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
pruneDeferredChildState()
|
|
4352
|
+
let otherSessionEntries = 0
|
|
4353
|
+
for (const watched of deferredChildWatch.values()) {
|
|
4354
|
+
if (watched.sessionID !== sessionID) otherSessionEntries += 1
|
|
4355
|
+
}
|
|
4356
|
+
if (otherSessionEntries + childIDs.length > MAX_DEFERRED_CHILD_WATCH) return false
|
|
4357
|
+
for (const childID of childIDs) {
|
|
4358
|
+
deferredChildWatch.set(childID, {
|
|
4359
|
+
sessionID,
|
|
4360
|
+
goalId: goal.goalId,
|
|
4361
|
+
runId: goal.runId,
|
|
4362
|
+
})
|
|
4363
|
+
}
|
|
4364
|
+
return true
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
// Bounded like every other runtime map in this file, but eviction must never
|
|
4368
|
+
// discard a watch a live goal is waiting on: that would strand it with no
|
|
4369
|
+
// diagnostic, which is the failure this whole mechanism exists to prevent.
|
|
4370
|
+
// Entries whose goal has been cleared, replaced, completed or stopped are
|
|
4371
|
+
// dead weight and are dropped first; the cap is only enforced against live
|
|
4372
|
+
// entries as a last resort.
|
|
4373
|
+
const deferralGoalIsLive = (watched) => {
|
|
4374
|
+
const goal = goalStates.get(watched.sessionID)
|
|
4375
|
+
return Boolean(
|
|
4376
|
+
goal && goal.goalId === watched.goalId && goal.runId === watched.runId && !goal.stopped,
|
|
4377
|
+
)
|
|
4378
|
+
}
|
|
4379
|
+
const pruneDeferredChildState = () => {
|
|
4380
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4381
|
+
if (!deferralGoalIsLive(watched)) deferredChildWatch.delete(childID)
|
|
4382
|
+
}
|
|
4383
|
+
for (const key of childDeferralNotices) {
|
|
4384
|
+
const [noticeSessionID, goalId, runId] = key.split("\u0000")
|
|
4385
|
+
if (!deferralGoalIsLive({ sessionID: noticeSessionID, goalId, runId })) {
|
|
4386
|
+
childDeferralNotices.delete(key)
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
const clearDeferredChildren = (sessionID) => {
|
|
4391
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4392
|
+
if (watched.sessionID === sessionID) deferredChildWatch.delete(childID)
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4215
4396
|
const claimContinuationSource = async (
|
|
4216
4397
|
sessionID,
|
|
4217
4398
|
goalID,
|
|
@@ -4246,10 +4427,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4246
4427
|
return null
|
|
4247
4428
|
}
|
|
4248
4429
|
|
|
4430
|
+
// Human intervention is evaluated before the active-children gate: a real
|
|
4431
|
+
// user message must pause the goal immediately, not once the subagents
|
|
4432
|
+
// happen to go idle.
|
|
4249
4433
|
const newHumanMessage =
|
|
4250
4434
|
refreshed.latestRealUserMessageID &&
|
|
4251
4435
|
refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
|
|
4252
|
-
if (
|
|
4436
|
+
if (
|
|
4437
|
+
!goal.options.noInterruptOnUserMessage &&
|
|
4438
|
+
(newHumanMessage || userInterventionDetected(messages, goal))
|
|
4439
|
+
) {
|
|
4440
|
+
childDeferralNotices.delete(childDeferralKey(sessionID, goal))
|
|
4253
4441
|
await pauseActiveGoal(sessionID, {
|
|
4254
4442
|
stopReason: "user intervention",
|
|
4255
4443
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -4258,6 +4446,70 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4258
4446
|
return null
|
|
4259
4447
|
}
|
|
4260
4448
|
|
|
4449
|
+
if (goal.options.noContinueWhileChildrenActive) {
|
|
4450
|
+
const deferralKey = childDeferralKey(sessionID, goal)
|
|
4451
|
+
const sequenceBeforeProbe = idleEventSequence
|
|
4452
|
+
let activeChildren = await activeChildSessionIDs(sessionID)
|
|
4453
|
+
// A child running a goal of its own consumes its idle events for that
|
|
4454
|
+
// goal, so it cannot deliver the wake this gate depends on. Deferring
|
|
4455
|
+
// behind one would strand the parent silently; the gate steps aside.
|
|
4456
|
+
const selfDrivenChildren = activeChildren.filter((childID) => goalStates.has(childID))
|
|
4457
|
+
if (selfDrivenChildren.length > 0) {
|
|
4458
|
+
await logChildActivityProbeFailure(
|
|
4459
|
+
"self-driven-child",
|
|
4460
|
+
`Active child session(s) ${selfDrivenChildren.join(", ")} run goals of their own and cannot wake this goal; continuing without the active-children gate`,
|
|
4461
|
+
new Error("watched child holds its own goal state"),
|
|
4462
|
+
)
|
|
4463
|
+
activeChildren = []
|
|
4464
|
+
}
|
|
4465
|
+
if (activeChildren.length > 0) {
|
|
4466
|
+
// Arm the watch, then confirm the children are still active. A child
|
|
4467
|
+
// that went idle while the first probe was in flight would already have
|
|
4468
|
+
// delivered its event, finding nothing armed, and the goal would wait
|
|
4469
|
+
// for a wake-up that can never come. Re-probing after arming closes
|
|
4470
|
+
// that window: from here on any transition is observed by the watch.
|
|
4471
|
+
if (!watchDeferredChildren(sessionID, goal, activeChildren)) {
|
|
4472
|
+
// More concurrent children than the watch can hold. Continuing is the
|
|
4473
|
+
// safe direction: the gate is an optimisation, a stranded goal is not.
|
|
4474
|
+
await logChildActivityProbeFailure(
|
|
4475
|
+
"watch-capacity",
|
|
4476
|
+
`Cannot track ${activeChildren.length} active child session(s) within the watch limit; continuing without the active-children gate`,
|
|
4477
|
+
new Error(`watch limit ${MAX_DEFERRED_CHILD_WATCH} exceeded`),
|
|
4478
|
+
)
|
|
4479
|
+
activeChildren = []
|
|
4480
|
+
} else {
|
|
4481
|
+
activeChildren = await activeChildSessionIDs(sessionID)
|
|
4482
|
+
// Drop any child that went idle while a probe was in flight: its wake
|
|
4483
|
+
// event has already been delivered and will not come again.
|
|
4484
|
+
activeChildren = activeChildren.filter(
|
|
4485
|
+
(childID) => !idledSince(childID, sequenceBeforeProbe),
|
|
4486
|
+
)
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
if (activeChildren.length > 0) {
|
|
4490
|
+
if (!childDeferralNotices.has(deferralKey)) {
|
|
4491
|
+
childDeferralNotices.add(deferralKey)
|
|
4492
|
+
goal.lastStatus =
|
|
4493
|
+
"Auto-continue deferred while a child session (subagent or background task) is still active. The goal is still running and continues once the children finish."
|
|
4494
|
+
// One entry per episode, not one per transition: history is a
|
|
4495
|
+
// 20-entry ring and a subagent-heavy run would otherwise evict
|
|
4496
|
+
// checkpoints and limit warnings.
|
|
4497
|
+
pushHistory(
|
|
4498
|
+
goal,
|
|
4499
|
+
"deferred",
|
|
4500
|
+
"Deferred auto-continue while child sessions were active.",
|
|
4501
|
+
)
|
|
4502
|
+
await persist(sessionID)
|
|
4503
|
+
}
|
|
4504
|
+
return null
|
|
4505
|
+
}
|
|
4506
|
+
clearDeferredChildren(sessionID)
|
|
4507
|
+
if (childDeferralNotices.delete(deferralKey)) {
|
|
4508
|
+
goal.lastStatus = "Child sessions went idle; auto-continue resumed."
|
|
4509
|
+
await persist(sessionID)
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4261
4513
|
if (
|
|
4262
4514
|
refreshed.latestAssistantID !== baseline.latestAssistantID ||
|
|
4263
4515
|
refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
|
|
@@ -4422,6 +4674,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4422
4674
|
|
|
4423
4675
|
const goal = goalStates.get(sessionID)
|
|
4424
4676
|
if (!goal || goal.stopped) return
|
|
4677
|
+
// With noInterruptOnUserMessage, a human message steers the running loop
|
|
4678
|
+
// instead of pausing the goal for /goal resume.
|
|
4679
|
+
if (goal.options.noInterruptOnUserMessage) return
|
|
4425
4680
|
await pauseActiveGoal(sessionID, {
|
|
4426
4681
|
stopReason: "user intervention",
|
|
4427
4682
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -5076,6 +5331,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5076
5331
|
if (!goal) return
|
|
5077
5332
|
goal.messageIDs = new Set()
|
|
5078
5333
|
goal.totalTokens = 0
|
|
5334
|
+
// Compaction rewrites the context: a continuation claim for a
|
|
5335
|
+
// pre-compaction source turn must not suppress the post-compaction
|
|
5336
|
+
// continuation (the recent tail can still end on the same assistant
|
|
5337
|
+
// message, which would otherwise stall the goal loop until the user
|
|
5338
|
+
// nudges it).
|
|
5339
|
+
goal.continuationClaim = null
|
|
5079
5340
|
await persist(sessionID)
|
|
5080
5341
|
return
|
|
5081
5342
|
}
|
|
@@ -5147,12 +5408,62 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5147
5408
|
|
|
5148
5409
|
if (!isIdleEvent(event)) return
|
|
5149
5410
|
|
|
5150
|
-
const
|
|
5411
|
+
const emittingSessionID = getSessionID(event)
|
|
5412
|
+
let sessionID = emittingSessionID
|
|
5413
|
+
// A child we deferred on has gone idle. The parent emits no event of its
|
|
5414
|
+
// own, so this is the only chance to re-drive its continuation. Consumed
|
|
5415
|
+
// once: unrelated children (the completion auditor's own session, for
|
|
5416
|
+
// example) are never watched and so can never trigger a continuation.
|
|
5417
|
+
let childWakeEvent = event?.[CHILD_WAKE_EVENT_FLAG] === true
|
|
5418
|
+
if (sessionID && !goalStates.has(sessionID)) recordChildIdle(sessionID)
|
|
5419
|
+
if (sessionID && deferredChildWatch.has(sessionID)) {
|
|
5420
|
+
const watched = deferredChildWatch.get(sessionID)
|
|
5421
|
+
deferredChildWatch.delete(sessionID)
|
|
5422
|
+
// The goal that deferred must still be the goal in focus. If it was
|
|
5423
|
+
// cleared, replaced, completed or restarted in the meantime, this wake
|
|
5424
|
+
// belongs to nothing and must not drive the goal that took its place.
|
|
5425
|
+
const parentGoal = goalStates.get(watched.sessionID)
|
|
5426
|
+
const parentStillWaiting =
|
|
5427
|
+
parentGoal &&
|
|
5428
|
+
parentGoal.goalId === watched.goalId &&
|
|
5429
|
+
parentGoal.runId === watched.runId
|
|
5430
|
+
if (parentStillWaiting && !goalStates.has(sessionID)) {
|
|
5431
|
+
sessionID = watched.sessionID
|
|
5432
|
+
childWakeEvent = true
|
|
5433
|
+
} else if (
|
|
5434
|
+
parentStillWaiting &&
|
|
5435
|
+
!childWakeInFlight.has(watched.sessionID) &&
|
|
5436
|
+
currentRuntime().sessionStatuses.get(watched.sessionID) === "idle"
|
|
5437
|
+
) {
|
|
5438
|
+
// The child acquired a goal of its own after being watched, so it
|
|
5439
|
+
// needs this event for its own loop. Serving only one of the two
|
|
5440
|
+
// would starve the other, so the parent is woken through a
|
|
5441
|
+
// synthesized idle of its own before the child's event continues.
|
|
5442
|
+
childWakeInFlight.add(watched.sessionID)
|
|
5443
|
+
try {
|
|
5444
|
+
await hooks.event({
|
|
5445
|
+
event: {
|
|
5446
|
+
type: "session.idle",
|
|
5447
|
+
properties: { sessionID: watched.sessionID },
|
|
5448
|
+
// B: the synthesized event is a wake pass like any other, so it
|
|
5449
|
+
// must not re-charge the stall gates for an assistant turn the
|
|
5450
|
+
// deferring pass already scored.
|
|
5451
|
+
[CHILD_WAKE_EVENT_FLAG]: true,
|
|
5452
|
+
},
|
|
5453
|
+
})
|
|
5454
|
+
} finally {
|
|
5455
|
+
childWakeInFlight.delete(watched.sessionID)
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
}
|
|
5151
5459
|
// Deprecated session.idle carries no status object but is itself an
|
|
5152
5460
|
// authoritative idle signal. Current session.status events were recorded
|
|
5153
|
-
// above before entering this branch.
|
|
5461
|
+
// above before entering this branch. Record it against the session that
|
|
5462
|
+
// actually emitted it: a child going idle says nothing about whether its
|
|
5463
|
+
// parent is idle, and claiming otherwise would defeat the idle guard in
|
|
5464
|
+
// the continuation claim.
|
|
5154
5465
|
if (event?.type === "session.idle") {
|
|
5155
|
-
currentRuntime().sessionStatuses.set(
|
|
5466
|
+
currentRuntime().sessionStatuses.set(emittingSessionID, "idle")
|
|
5156
5467
|
}
|
|
5157
5468
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
5158
5469
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
@@ -5229,7 +5540,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5229
5540
|
// Latest instruction wins: if a real (non-plugin) user message arrived
|
|
5230
5541
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
5231
5542
|
// human. They can /goal resume to hand control back to the plugin.
|
|
5232
|
-
if (
|
|
5543
|
+
if (
|
|
5544
|
+
!activeGoalAfterMessages.options.noInterruptOnUserMessage &&
|
|
5545
|
+
userInterventionDetected(messages, activeGoalAfterMessages)
|
|
5546
|
+
) {
|
|
5233
5547
|
await pauseActiveGoal(sessionID, {
|
|
5234
5548
|
stopReason: "user intervention",
|
|
5235
5549
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -5575,7 +5889,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5575
5889
|
!latestHasToolCall &&
|
|
5576
5890
|
!latestHasThinkingTokens &&
|
|
5577
5891
|
(assistantRepeated || !latestText || !assistantChanged)
|
|
5578
|
-
|
|
5892
|
+
// A child-wake pass re-examines an assistant turn the parent already
|
|
5893
|
+
// produced and was already charged for: the parent ran nothing in
|
|
5894
|
+
// between. Charging the stall gates again would pause a healthy goal
|
|
5895
|
+
// after one talk-only turn plus one deferral.
|
|
5896
|
+
if (lowOutputLooksStalled && !childWakeEvent) {
|
|
5579
5897
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
5580
5898
|
if (
|
|
5581
5899
|
activeGoalAfterMessages.noProgressTurns >=
|
|
@@ -5615,7 +5933,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5615
5933
|
"warning",
|
|
5616
5934
|
`Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
|
|
5617
5935
|
)
|
|
5618
|
-
} else if (
|
|
5936
|
+
} else if (
|
|
5937
|
+
// A wake pass observes the same assistant turn the deferring pass
|
|
5938
|
+
// already scored, so it must neither charge nor clear the counter.
|
|
5939
|
+
// Resetting here would let an alternating defer/wake cycle keep a
|
|
5940
|
+
// genuinely stalled loop running indefinitely.
|
|
5941
|
+
!childWakeEvent &&
|
|
5942
|
+
(latestOutputTokens !== null || assistantChanged || !latestAssistant)
|
|
5943
|
+
) {
|
|
5619
5944
|
activeGoalAfterMessages.noProgressTurns = 0
|
|
5620
5945
|
}
|
|
5621
5946
|
|
|
@@ -5636,7 +5961,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5636
5961
|
!activationBoundary &&
|
|
5637
5962
|
Boolean(latestAssistant) &&
|
|
5638
5963
|
!latestHasToolCall
|
|
5639
|
-
if (noToolCallContinuation && !lowOutputLooksStalled) {
|
|
5964
|
+
if (noToolCallContinuation && !lowOutputLooksStalled && !childWakeEvent) {
|
|
5640
5965
|
activeGoalAfterMessages.noToolCallTurns += 1
|
|
5641
5966
|
if (
|
|
5642
5967
|
activeGoalAfterMessages.noToolCallTurns >=
|
|
@@ -8,7 +8,7 @@ const SHAPE_ERROR_PATTERNS = [
|
|
|
8
8
|
// Only read-only operations may be retried with another argument shape. A
|
|
9
9
|
// TypeError can be raised after a mutating SDK call has already reached the
|
|
10
10
|
// host, so replaying create/prompt/update/delete/abort could duplicate side effects.
|
|
11
|
-
const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get"])
|
|
11
|
+
const REPLAY_SAFE_OPERATIONS = new Set(["messages", "get", "children", "status"])
|
|
12
12
|
|
|
13
13
|
function isArgumentShapeError(error) {
|
|
14
14
|
if (!(error instanceof TypeError)) return false
|
|
@@ -70,6 +70,12 @@ export function createOpenCodeSessionApi(client, options = {}) {
|
|
|
70
70
|
{ path: { id: sessionID }, query: options },
|
|
71
71
|
)
|
|
72
72
|
},
|
|
73
|
+
children(sessionID) {
|
|
74
|
+
return invoke("children", { sessionID }, { path: { id: sessionID } })
|
|
75
|
+
},
|
|
76
|
+
status() {
|
|
77
|
+
return invoke("status", {}, { path: {} })
|
|
78
|
+
},
|
|
73
79
|
promptAsync(sessionID, input = {}) {
|
|
74
80
|
return invoke(
|
|
75
81
|
"promptAsync",
|