opencode-goal-plugin 0.6.3 → 0.6.4

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
@@ -1,6 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.4 — 2026-07-12
4
+
5
+ - Re-check session status and recent messages after an auto-continue cooldown, pause immediately for human intervention, Plan-agent switches, permission rejection, aborts, and provider errors, and abort an accepted continuation when the user takes control.
6
+ - Persist one continuation claim per goal execution epoch and source assistant turn to prevent sequential duplicate idle events, while preserving the initiating agent, model, and variant on every continuation prompt.
4
7
 
5
8
  ## 0.6.3 — 2026-07-11
6
9
 
package/README.md CHANGED
@@ -193,10 +193,10 @@ An ordered sequence, run as a strict pipeline:
193
193
  ## How it works
194
194
 
195
195
  1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn.
196
- 2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done.
196
+ 2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. Continuations retain the agent, provider/model, and variant that initiated the goal. Before sending after a cooldown, the plugin re-checks that the session is still idle and no human message, newer assistant turn, Plan-agent switch, rejected permission, abort, or provider error has superseded the request.
197
197
  3. The plugin stops auto-continuing when the assistant ends a response with a substantiated `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. A `[goal:complete]` is only honored when it is preceded by a `[goal:evidence]` line; a `[goal:blocked]` is only honored when a concrete blocker is stated. Unsubstantiated claims are rejected and the plugin re-prompts for the missing evidence or blocker.
198
198
  4. If OpenCode compacts the session, the plugin injects a deterministic summary into the compaction context so the goal survives the compaction and the assistant keeps the thread. The summary — objective, status, budget usage, recent checkpoints, and recent lifecycle events — is reconstructed from the plugin's persisted goal record rather than from chat memory, so it is stable and reproducible. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation.
199
- 5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction and pauses auto-continue so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). Run `/goal resume` to hand control back to the goal loop.
199
+ 5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction, pauses auto-continue, and asks OpenCode to abort an already accepted continuation so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). A durable claim on the source assistant turn also prevents different idle event IDs from sending the same continuation twice. Run `/goal resume` to hand control back to the goal loop.
200
200
 
201
201
  ## Completion markers
202
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -87,7 +87,10 @@ function createRuntimeState() {
87
87
  seenOutputTokens: new Map(),
88
88
  activeContinues: new Map(),
89
89
  continuationControllers: new Map(),
90
+ promptInFlightSessions: new Set(),
90
91
  seenIdleEventIDs: new Set(),
92
+ sessionStatuses: new Map(),
93
+ sessionExecutionContexts: new Map(),
91
94
  readOnlyCommandGuards: new Set(),
92
95
  ledgerSink: null,
93
96
  persistenceLease: null,
@@ -242,7 +245,13 @@ function makeContinuationPart(text) {
242
245
  }
243
246
 
244
247
  function getSessionID(event) {
245
- return event?.properties?.sessionID || event?.properties?.info?.sessionID || null
248
+ return (
249
+ event?.properties?.sessionID ||
250
+ event?.properties?.info?.sessionID ||
251
+ event?.data?.sessionID ||
252
+ event?.data?.info?.sessionID ||
253
+ null
254
+ )
246
255
  }
247
256
 
248
257
  function isIdleEvent(event) {
@@ -252,12 +261,76 @@ function isIdleEvent(event) {
252
261
  )
253
262
  }
254
263
 
255
- function isAbortErrorEvent(event) {
256
- if (event?.type !== "session.error") return false
257
- const error = event?.properties?.error
264
+ function normalizeExecutionContext(value) {
265
+ if (!isPlainObject(value)) return null
266
+ const model = isPlainObject(value.model) ? value.model : {}
267
+ const boundedContextText = (candidate) => {
268
+ if (typeof candidate !== "string") return ""
269
+ const normalized = candidate.trim()
270
+ return normalized.length <= MAX_GOAL_META_LENGTH ? normalized : ""
271
+ }
272
+ const agent = boundedContextText(value.agent)
273
+ const providerID = boundedContextText(model.providerID)
274
+ const modelID =
275
+ boundedContextText(model.modelID) || boundedContextText(model.id)
276
+ const variantValue = value.variant ?? model.variant
277
+ const variant = boundedContextText(variantValue)
278
+ if (!agent && !(providerID && modelID) && !variant) return null
279
+ return {
280
+ ...(agent ? { agent } : {}),
281
+ ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
282
+ ...(variant ? { variant } : {}),
283
+ }
284
+ }
285
+
286
+ function continuationContextInput(goal) {
287
+ const context = normalizeExecutionContext(goal?.executionContext)
288
+ return context ? { ...context } : {}
289
+ }
290
+
291
+ function isPlanAgent(agent) {
292
+ return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
293
+ }
294
+
295
+ function terminalEvent(event) {
296
+ const permissionReply = String(
297
+ event?.properties?.reply ??
298
+ event?.properties?.response ??
299
+ event?.data?.reply ??
300
+ event?.data?.response ??
301
+ "",
302
+ )
303
+ if (event?.type === "permission.replied" && /^(?:reject(?:ed)?|deny|denied)$/i.test(permissionReply)) {
304
+ return {
305
+ sessionID: getSessionID(event),
306
+ stopReason: "permission rejected",
307
+ status: "Goal paused after a permission request was rejected.",
308
+ history: "Paused after OpenCode reported a rejected permission request.",
309
+ }
310
+ }
311
+
312
+ let error = null
313
+ if (event?.type === "session.error") {
314
+ error = event?.properties?.error || event?.data?.error
315
+ } else if (event?.type === "message.updated") {
316
+ error = messageInfoFromEvent(event)?.error
317
+ }
318
+ if (!error) return null
319
+
258
320
  const name = String(error?.name || error?.data?.name || "")
259
321
  const message = String(error?.message || error?.data?.message || "")
260
- return name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
322
+ const aborted = name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
323
+ const summary = summarizeText(`${name}${message ? `: ${message}` : ""}`, 240) || "unknown provider error"
324
+ return {
325
+ sessionID: getSessionID(event) || messageSessionID(messageInfoFromEvent(event)),
326
+ stopReason: aborted ? "user interrupted" : "provider error",
327
+ status: aborted
328
+ ? "Goal paused after user interruption."
329
+ : `Goal paused after a terminal provider error: ${summary}`,
330
+ history: aborted
331
+ ? "Paused after OpenCode reported that the active turn was aborted."
332
+ : `Paused after OpenCode reported a terminal provider error: ${summary}`,
333
+ }
261
334
  }
262
335
 
263
336
  function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
@@ -706,7 +779,10 @@ function clearRuntimeState() {
706
779
  seenOutputTokens.clear()
707
780
  activeContinues.clear()
708
781
  runtime.continuationControllers.clear()
782
+ runtime.promptInFlightSessions.clear()
709
783
  runtime.seenIdleEventIDs.clear()
784
+ runtime.sessionStatuses.clear()
785
+ runtime.sessionExecutionContexts.clear()
710
786
  runtime.readOnlyCommandGuards.clear()
711
787
  }
712
788
 
@@ -805,6 +881,7 @@ function resetGoalBudget(goal) {
805
881
  goal.promptFailures = 0
806
882
  goal.formatFailures = 0
807
883
  goal.lastAssistantMessageID = ""
884
+ goal.continuationClaim = null
808
885
  goal.skipNextTerminalCheck = false
809
886
  goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
810
887
  }
@@ -1109,6 +1186,18 @@ function normalizePersistedGoal(rawGoal) {
1109
1186
  stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
1110
1187
  promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
1111
1188
  formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
1189
+ executionContext: normalizeExecutionContext(rawGoal.executionContext),
1190
+ continuationClaim:
1191
+ isPlainObject(rawGoal.continuationClaim) &&
1192
+ typeof rawGoal.continuationClaim.runId === "string" &&
1193
+ rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
1194
+ typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
1195
+ rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
1196
+ ? {
1197
+ runId: rawGoal.continuationClaim.runId,
1198
+ sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
1199
+ }
1200
+ : null,
1112
1201
  messageIDs: Array.isArray(rawGoal.messageIDs)
1113
1202
  ? rawGoal.messageIDs.slice(-MAX_MESSAGE_IDS_PER_GOAL).filter((messageID) => typeof messageID === "string" && messageID.length <= MAX_GOAL_META_LENGTH)
1114
1203
  : [],
@@ -1181,6 +1270,9 @@ function deserializeGoal(goal) {
1181
1270
  "Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
1182
1271
  )
1183
1272
  }
1273
+ // Recovered goals always require an explicit resume, which starts a fresh
1274
+ // execution epoch and makes any pre-crash continuation claim obsolete.
1275
+ hydrated.continuationClaim = null
1184
1276
 
1185
1277
  return hydrated
1186
1278
  }
@@ -1898,7 +1990,8 @@ function messageRole(message) {
1898
1990
  }
1899
1991
 
1900
1992
  function messageID(message) {
1901
- return message?.info?.id || message?.id || ""
1993
+ const id = message?.info?.id || message?.id || ""
1994
+ return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : ""
1902
1995
  }
1903
1996
 
1904
1997
  function messageSessionID(message) {
@@ -1986,6 +2079,9 @@ function messageInfoFromEvent(event) {
1986
2079
  event?.properties?.info,
1987
2080
  event?.properties?.message?.info,
1988
2081
  event?.properties?.message,
2082
+ event?.data?.info,
2083
+ event?.data?.message?.info,
2084
+ event?.data?.message,
1989
2085
  ]
1990
2086
  return candidates.find(isPlainObject) || null
1991
2087
  }
@@ -2049,6 +2145,35 @@ function findLatestAssistantMessage(messages) {
2049
2145
  return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
2050
2146
  }
2051
2147
 
2148
+ function findLatestExecutionContext(messages) {
2149
+ for (const message of [...(messages || [])].reverse()) {
2150
+ if (messageRole(message) !== "user") continue
2151
+ const info = isPlainObject(message?.info) ? message.info : message
2152
+ const context = normalizeExecutionContext(info)
2153
+ if (context) return context
2154
+ }
2155
+ return null
2156
+ }
2157
+
2158
+ function continuationSnapshot(messages) {
2159
+ const list = Array.isArray(messages) ? messages : []
2160
+ const latestAssistant = findLatestAssistantMessage(list)
2161
+ const latestRealUser = [...list]
2162
+ .reverse()
2163
+ .find((message) => messageRole(message) === "user" && !isPluginContinuationMessage(message))
2164
+ const latestRelevant = [...list]
2165
+ .reverse()
2166
+ .find((message) =>
2167
+ (messageRole(message) === "assistant" || messageRole(message) === "user") &&
2168
+ !isPluginContinuationMessage(message),
2169
+ )
2170
+ return {
2171
+ latestAssistantID: messageID(latestAssistant),
2172
+ latestRealUserMessageID: messageID(latestRealUser),
2173
+ latestRelevantMessageID: messageID(latestRelevant),
2174
+ }
2175
+ }
2176
+
2052
2177
  // The plugin drives auto-continue by sending its own prompts via promptAsync,
2053
2178
  // which appear in the session as user-role messages. Every such prompt is
2054
2179
  // framed inside <goal_continuation>, so a user message containing that marker
@@ -2136,6 +2261,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2136
2261
  stopReason: "",
2137
2262
  promptFailures: 0,
2138
2263
  formatFailures: 0,
2264
+ executionContext: normalizeExecutionContext(
2265
+ meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
2266
+ ),
2267
+ continuationClaim: null,
2139
2268
  messageIDs: new Set(),
2140
2269
  history: [],
2141
2270
  checkpoints: [],
@@ -2882,6 +3011,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2882
3011
 
2883
3012
  const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
2884
3013
 
3014
+ const abortAcceptedContinuation = async (sessionID) => {
3015
+ const runtimeState = currentRuntime()
3016
+ runtimeState.continuationControllers.get(sessionID)?.abort()
3017
+ if (
3018
+ !runtimeState.promptInFlightSessions.has(sessionID) ||
3019
+ typeof client?.session?.abort !== "function"
3020
+ ) {
3021
+ return
3022
+ }
3023
+ try {
3024
+ await sessionApi.abort(sessionID)
3025
+ } catch (error) {
3026
+ await logPluginError(client, "Failed to abort an accepted auto-continue after intervention", error)
3027
+ }
3028
+ }
3029
+
3030
+ const pauseActiveGoal = async (
3031
+ sessionID,
3032
+ { stopReason: reason, status, history, abortAccepted = false },
3033
+ ) => {
3034
+ const goal = goalStates.get(sessionID)
3035
+ if (!goal) return false
3036
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
3037
+ goal.stopped = true
3038
+ goal.stopReason = reason
3039
+ goal.lastStatus = `${status} Run /${commandName} resume to continue.`
3040
+ goal.continuationClaim = null
3041
+ pushHistory(goal, "paused", history)
3042
+ activeContinues.delete(sessionID)
3043
+ await persist()
3044
+ if (abortAccepted) await abortAcceptedContinuation(sessionID)
3045
+ return true
3046
+ }
3047
+
3048
+ const claimContinuationSource = async (
3049
+ sessionID,
3050
+ goalID,
3051
+ runID,
3052
+ baselineMessages,
3053
+ { refreshMessages = false } = {},
3054
+ ) => {
3055
+ const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
3056
+ if (!goalBeforeRefresh) return null
3057
+ const hostMessages = refreshMessages
3058
+ ? await sessionApi.messages(sessionID, {
3059
+ limit: goalBeforeRefresh.options.maxRecentMessages,
3060
+ })
3061
+ : baselineMessages
3062
+ const goal = activeGoal(sessionID, goalID, runID)
3063
+ if (!goal) return null
3064
+ const messages = Array.isArray(hostMessages)
3065
+ ? hostMessages.slice(-goal.options.maxRecentMessages)
3066
+ : []
3067
+ const baseline = continuationSnapshot(baselineMessages)
3068
+ const refreshed = continuationSnapshot(messages)
3069
+
3070
+ if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
3071
+
3072
+ const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
3073
+ if (isPlanAgent(currentContext?.agent)) {
3074
+ await pauseActiveGoal(sessionID, {
3075
+ stopReason: "plan agent active",
3076
+ status: "Auto-continue paused because the active agent switched to Plan.",
3077
+ history: "Paused before auto-continue because the active session agent switched to Plan.",
3078
+ })
3079
+ return null
3080
+ }
3081
+
3082
+ const newHumanMessage =
3083
+ refreshed.latestRealUserMessageID &&
3084
+ refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
3085
+ if (newHumanMessage || userInterventionDetected(messages, goal)) {
3086
+ await pauseActiveGoal(sessionID, {
3087
+ stopReason: "user intervention",
3088
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3089
+ history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
3090
+ })
3091
+ return null
3092
+ }
3093
+
3094
+ if (
3095
+ refreshed.latestAssistantID !== baseline.latestAssistantID ||
3096
+ refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
3097
+ ) {
3098
+ return null
3099
+ }
3100
+
3101
+ if (!goal.executionContext) {
3102
+ goal.executionContext = findLatestExecutionContext(messages)
3103
+ }
3104
+ const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
3105
+ if (
3106
+ goal.continuationClaim?.runId === runID &&
3107
+ goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
3108
+ ) {
3109
+ return null
3110
+ }
3111
+
3112
+ goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
3113
+ const claimPersisted = await persist()
3114
+ if (!claimPersisted && persistenceOptions.persistState) {
3115
+ goal.continuationClaim = null
3116
+ goal.stopped = true
3117
+ goal.stopReason = "continuation claim persistence failed"
3118
+ goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
3119
+ pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
3120
+ return null
3121
+ }
3122
+ return goal
3123
+ }
3124
+
2885
3125
  const hooks = {
2886
3126
  config: async (config) => {
2887
3127
  applyNativeGoalConfig(config, {
@@ -2890,6 +3130,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2890
3130
  })
2891
3131
  if (pluginOptions.completionAudit) verifierRegistrationReady = true
2892
3132
  },
3133
+ "chat.params": async (input) => {
3134
+ if (!input?.sessionID) return
3135
+ const context = normalizeExecutionContext({
3136
+ agent: input.agent,
3137
+ model: input.model,
3138
+ variant: input?.message?.model?.variant,
3139
+ })
3140
+ if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
3141
+ },
3142
+ "chat.message": async (input, output) => {
3143
+ const sessionID = input?.sessionID
3144
+ if (!sessionID) return
3145
+ const context = normalizeExecutionContext(input)
3146
+ if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3147
+
3148
+ const message = { role: "user", parts: Array.isArray(output?.parts) ? output.parts : [] }
3149
+ if (isPluginContinuationMessage(message)) return
3150
+ const text = getText(message.parts)
3151
+ const commandPrefix = `/${commandName}`
3152
+ if (text === commandPrefix || text.startsWith(`${commandPrefix} `)) return
3153
+
3154
+ const goal = goalStates.get(sessionID)
3155
+ if (!goal || goal.stopped) return
3156
+ await pauseActiveGoal(sessionID, {
3157
+ stopReason: "user intervention",
3158
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3159
+ history: "Paused immediately when a new human message arrived; latest instruction wins.",
3160
+ abortAccepted: true,
3161
+ })
3162
+ },
2893
3163
  "tool.execute.before": async (input) => {
2894
3164
  const sessionID = input?.sessionID
2895
3165
  if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
@@ -2985,11 +3255,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2985
3255
  output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
2986
3256
  return
2987
3257
  }
3258
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
2988
3259
  goal.stopped = true
2989
3260
  goal.stopReason = "paused"
2990
3261
  goal.lastStatus = "Goal paused."
3262
+ goal.continuationClaim = null
3263
+ activeContinues.delete(sessionID)
2991
3264
  pushHistory(goal, "paused", "User paused the active goal.")
2992
3265
  await persist()
3266
+ await abortAcceptedContinuation(sessionID)
2993
3267
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
2994
3268
  return
2995
3269
  }
@@ -3051,6 +3325,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3051
3325
  goal.noProgressTurns = 0
3052
3326
  goal.noToolCallTurns = 0
3053
3327
  goal.formatFailures = 0
3328
+ goal.continuationClaim = null
3054
3329
  goal.lastStatus = "Goal objective updated."
3055
3330
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3056
3331
  await persist()
@@ -3324,17 +3599,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3324
3599
  },
3325
3600
 
3326
3601
  event: async ({ event }) => {
3327
- if (isAbortErrorEvent(event)) {
3602
+ if (event?.type === "session.status") {
3328
3603
  const sessionID = getSessionID(event)
3329
- const goal = goalStates.get(sessionID)
3330
- if (!goal) return
3331
- currentRuntime().continuationControllers.get(sessionID)?.abort()
3332
- goal.stopped = true
3333
- goal.stopReason = "user interrupted"
3334
- goal.lastStatus = `Goal paused after user interruption. Run /${commandName} resume to continue.`
3335
- pushHistory(goal, "paused", "Paused after OpenCode reported that the user interrupted the active turn.")
3336
- activeContinues.delete(sessionID)
3337
- await persist()
3604
+ const status = event?.properties?.status?.type || event?.data?.status?.type
3605
+ if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
3606
+ }
3607
+
3608
+ if (event?.type === "session.updated") {
3609
+ const sessionID = getSessionID(event)
3610
+ const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
3611
+ if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3612
+ }
3613
+
3614
+ if (event?.type === "message.updated") {
3615
+ const message = messageInfoFromEvent(event)
3616
+ if (messageRole(message) === "user") {
3617
+ const context = normalizeExecutionContext(message)
3618
+ const sessionID = messageSessionID(message) || getSessionID(event)
3619
+ if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3620
+ }
3621
+ }
3622
+
3623
+ const terminal = terminalEvent(event)
3624
+ if (terminal?.sessionID) {
3625
+ await pauseActiveGoal(terminal.sessionID, {
3626
+ ...terminal,
3627
+ abortAccepted: true,
3628
+ })
3338
3629
  return
3339
3630
  }
3340
3631
 
@@ -3410,6 +3701,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3410
3701
  if (!isIdleEvent(event)) return
3411
3702
 
3412
3703
  const sessionID = getSessionID(event)
3704
+ // Deprecated session.idle carries no status object but is itself an
3705
+ // authoritative idle signal. Current session.status events were recorded
3706
+ // above before entering this branch.
3707
+ if (event?.type === "session.idle") {
3708
+ currentRuntime().sessionStatuses.set(sessionID, "idle")
3709
+ }
3413
3710
  currentRuntime().readOnlyCommandGuards.delete(sessionID)
3414
3711
  const eventID = typeof event?.id === "string" ? event.id : ""
3415
3712
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
@@ -3429,6 +3726,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3429
3726
 
3430
3727
  const continueToken = randomUUID()
3431
3728
  const continueController = new AbortController()
3729
+ let claimedSourceAssistantMessageID = ""
3432
3730
  activeContinues.set(sessionID, continueToken)
3433
3731
  currentRuntime().continuationControllers.set(sessionID, continueController)
3434
3732
  try {
@@ -3440,9 +3738,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3440
3738
  : []
3441
3739
  const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
3442
3740
  if (!activeGoalAfterMessages) return
3741
+ if (!activeGoalAfterMessages.executionContext) {
3742
+ activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
3743
+ }
3443
3744
 
3444
3745
  const latestAssistant = findLatestAssistantMessage(messages)
3445
- const latestAssistantID = latestAssistant?.info?.id || ""
3746
+ const latestAssistantID = messageID(latestAssistant)
3446
3747
  const latestText = getText(latestAssistant?.parts)
3447
3748
  const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
3448
3749
  const previousAssistantText = activeGoalAfterMessages.lastAssistantText
@@ -3462,16 +3763,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3462
3763
  // since the last auto-continue, stop driving the loop and defer to the
3463
3764
  // human. They can /goal resume to hand control back to the plugin.
3464
3765
  if (userInterventionDetected(messages, activeGoalAfterMessages)) {
3465
- activeGoalAfterMessages.stopped = true
3466
- activeGoalAfterMessages.stopReason = "user intervention"
3467
- activeGoalAfterMessages.lastStatus =
3468
- `Auto-continue paused: you sent a new message, so the latest instruction wins. Run /${commandName} resume to continue the goal.`
3469
- pushHistory(
3470
- activeGoalAfterMessages,
3471
- "paused",
3472
- "Paused auto-continue after a real user message arrived; latest instruction wins.",
3473
- )
3474
- await persist()
3766
+ await pauseActiveGoal(sessionID, {
3767
+ stopReason: "user intervention",
3768
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3769
+ history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
3770
+ })
3771
+ return
3772
+ }
3773
+
3774
+ const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
3775
+ if (
3776
+ activeGoalAfterMessages.continuationClaim?.runId === runID &&
3777
+ activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
3778
+ sourceAssistantMessageID
3779
+ ) {
3475
3780
  return
3476
3781
  }
3477
3782
 
@@ -3612,14 +3917,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3612
3917
  const limitReason = stopReason(activeGoalAfterMessages)
3613
3918
  if (limitReason) {
3614
3919
  if (!activeGoalAfterMessages.budgetWrapupSent) {
3615
- activeGoalAfterMessages.budgetWrapupSent = true
3616
- activeGoalAfterMessages.stopped = true
3617
- activeGoalAfterMessages.stopReason = limitReason
3618
- activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
3619
- pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
3620
- await sessionApi.promptAsync(sessionID, {
3621
- parts: [makeContinuationPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))],
3622
- })
3920
+ const claimedGoal = await claimContinuationSource(
3921
+ sessionID,
3922
+ goalID,
3923
+ runID,
3924
+ messages,
3925
+ )
3926
+ if (!claimedGoal) return
3927
+ claimedSourceAssistantMessageID =
3928
+ claimedGoal.continuationClaim?.sourceAssistantMessageID || ""
3929
+ claimedGoal.budgetWrapupSent = true
3930
+ claimedGoal.stopped = true
3931
+ claimedGoal.stopReason = limitReason
3932
+ claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
3933
+ pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
3934
+ await persist()
3935
+ currentRuntime().promptInFlightSessions.add(sessionID)
3936
+ let response
3937
+ try {
3938
+ response = await sessionApi.promptAsync(sessionID, {
3939
+ ...continuationContextInput(claimedGoal),
3940
+ parts: [makeContinuationPart(buildContinueMessage(claimedGoal, { budgetWrapup: true }))],
3941
+ })
3942
+ } finally {
3943
+ currentRuntime().promptInFlightSessions.delete(sessionID)
3944
+ }
3945
+ if (response?.error) {
3946
+ claimedGoal.lastStatus = `${limitReason}; final handoff request failed: ${response.error.name || "unknown error"}.`
3947
+ pushHistory(claimedGoal, "error", claimedGoal.lastStatus)
3948
+ }
3623
3949
  } else {
3624
3950
  activeGoalAfterMessages.stopped = true
3625
3951
  activeGoalAfterMessages.stopReason = limitReason
@@ -3741,6 +4067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3741
4067
  }
3742
4068
 
3743
4069
  const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
4070
+ let cooldownWaited = false
3744
4071
  if (
3745
4072
  activeGoalAfterMessages.lastContinueAt &&
3746
4073
  elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
@@ -3750,10 +4077,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3750
4077
  continueController.signal,
3751
4078
  )
3752
4079
  if (!delayCompleted) return
4080
+ cooldownWaited = true
3753
4081
  }
3754
4082
 
3755
- const activeGoalBeforePrompt = activeGoal(sessionID, goalID, runID)
4083
+ const activeGoalBeforePrompt = await claimContinuationSource(
4084
+ sessionID,
4085
+ goalID,
4086
+ runID,
4087
+ messages,
4088
+ { refreshMessages: cooldownWaited },
4089
+ )
3756
4090
  if (!activeGoalBeforePrompt) return
4091
+ claimedSourceAssistantMessageID =
4092
+ activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
3757
4093
 
3758
4094
  const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
3759
4095
  if (budgetWrapup) {
@@ -3810,22 +4146,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3810
4146
  }
3811
4147
  }
3812
4148
 
3813
- const response = await sessionApi.promptAsync(sessionID, {
3814
- parts: [
3815
- makeContinuationPart(
3816
- buildContinueMessage(activeGoalBeforePrompt, {
3817
- budgetWrapup,
3818
- completionUnverified,
3819
- blockerUnstated,
3820
- }),
3821
- ),
3822
- ],
3823
- })
4149
+ currentRuntime().promptInFlightSessions.add(sessionID)
4150
+ let response
4151
+ try {
4152
+ response = await sessionApi.promptAsync(sessionID, {
4153
+ ...continuationContextInput(activeGoalBeforePrompt),
4154
+ parts: [
4155
+ makeContinuationPart(
4156
+ buildContinueMessage(activeGoalBeforePrompt, {
4157
+ budgetWrapup,
4158
+ completionUnverified,
4159
+ blockerUnstated,
4160
+ }),
4161
+ ),
4162
+ ],
4163
+ })
4164
+ } finally {
4165
+ currentRuntime().promptInFlightSessions.delete(sessionID)
4166
+ }
3824
4167
 
3825
4168
  if (response.error) {
3826
4169
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3827
4170
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
3828
- if (activeGoalAfterPrompt) {
4171
+ if (
4172
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4173
+ claimedSourceAssistantMessageID
4174
+ ) {
4175
+ activeGoalAfterPrompt.continuationClaim = null
3829
4176
  activeGoalAfterPrompt.promptFailures += 1
3830
4177
  activeGoalAfterPrompt.lastStatus = message
3831
4178
  pushHistory(activeGoalAfterPrompt, "error", message)
@@ -3838,7 +4185,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3838
4185
  await logPluginError(client, message, response.error)
3839
4186
  } else {
3840
4187
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3841
- if (activeGoalAfterPrompt) {
4188
+ if (
4189
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4190
+ claimedSourceAssistantMessageID
4191
+ ) {
3842
4192
  // Decrement rather than reset: an alternating error/success pattern
3843
4193
  // should still accumulate toward the circuit-breaker cap over time,
3844
4194
  // matching the formatFailures approach for the same reason.
@@ -3856,6 +4206,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3856
4206
  } catch (error) {
3857
4207
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
3858
4208
  if (activeGoalAfterError) {
4209
+ if (
4210
+ claimedSourceAssistantMessageID &&
4211
+ activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
4212
+ claimedSourceAssistantMessageID
4213
+ ) {
4214
+ activeGoalAfterError.continuationClaim = null
4215
+ }
3859
4216
  activeGoalAfterError.promptFailures += 1
3860
4217
  const message = `Auto-continue failed: ${error?.message || error}`
3861
4218
  activeGoalAfterError.lastStatus = message
@@ -3869,6 +4226,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3869
4226
  }
3870
4227
  await logPluginError(client, "Auto-continue failed", error)
3871
4228
  } finally {
4229
+ currentRuntime().promptInFlightSessions.delete(sessionID)
3872
4230
  // Only delete our own entry. If cleanupGoal already removed it (because
3873
4231
  // the goal completed) and a new handler has since set a fresh token,
3874
4232
  // we must not clobber the new handler's guard.