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 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.7.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": "5.9.3"
75
+ "typescript": "7.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "zod": "4.1.8"
78
+ "zod": "4.4.3"
79
79
  }
80
80
  }
@@ -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 (newHumanMessage || userInterventionDetected(messages, goal)) {
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 sessionID = getSessionID(event)
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(sessionID, "idle")
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 (userInterventionDetected(messages, activeGoalAfterMessages)) {
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
- if (lowOutputLooksStalled) {
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 (latestOutputTokens !== null || assistantChanged || !latestAssistant) {
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",