opencode-goal-plugin 0.6.2 → 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,13 @@
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.
7
+
8
+ ## 0.6.3 — 2026-07-11
9
+
10
+ - Clarify the textual completion protocol so models keep evidence and completion on consecutive plain-text lines instead of wrapping markers in Markdown or separating them with blank lines.
4
11
 
5
12
  ## 0.6.2 — 2026-07-11
6
13
 
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.2",
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
  }
@@ -1756,11 +1848,16 @@ function buildContinueMessage(
1756
1848
  )
1757
1849
  } else {
1758
1850
  lines.push(
1759
- "Continue with the next concrete step; inspect current state and repair failures.",
1851
+ "Continue the next concrete step; inspect and repair failures.",
1760
1852
  )
1761
1853
  }
1762
1854
 
1763
- lines.push("Complete only after verification: `[goal:evidence] …` then `[goal:complete]`. If only user input can unblock work, state why then `[goal:blocked]`.")
1855
+ lines.push(
1856
+ "Completion format—consecutive plain lines; no Markdown/backticks/blank line:",
1857
+ "[goal:evidence] <proof>",
1858
+ "[goal:complete]",
1859
+ "Need user input? State why before [goal:blocked].",
1860
+ )
1764
1861
  const limitWarning = buildLimitWarning(goal)
1765
1862
  if (limitWarning) lines.push(limitWarning.trim())
1766
1863
 
@@ -1893,7 +1990,8 @@ function messageRole(message) {
1893
1990
  }
1894
1991
 
1895
1992
  function messageID(message) {
1896
- 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 : ""
1897
1995
  }
1898
1996
 
1899
1997
  function messageSessionID(message) {
@@ -1981,6 +2079,9 @@ function messageInfoFromEvent(event) {
1981
2079
  event?.properties?.info,
1982
2080
  event?.properties?.message?.info,
1983
2081
  event?.properties?.message,
2082
+ event?.data?.info,
2083
+ event?.data?.message?.info,
2084
+ event?.data?.message,
1984
2085
  ]
1985
2086
  return candidates.find(isPlainObject) || null
1986
2087
  }
@@ -2044,6 +2145,35 @@ function findLatestAssistantMessage(messages) {
2044
2145
  return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
2045
2146
  }
2046
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
+
2047
2177
  // The plugin drives auto-continue by sending its own prompts via promptAsync,
2048
2178
  // which appear in the session as user-role messages. Every such prompt is
2049
2179
  // framed inside <goal_continuation>, so a user message containing that marker
@@ -2131,6 +2261,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2131
2261
  stopReason: "",
2132
2262
  promptFailures: 0,
2133
2263
  formatFailures: 0,
2264
+ executionContext: normalizeExecutionContext(
2265
+ meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
2266
+ ),
2267
+ continuationClaim: null,
2134
2268
  messageIDs: new Set(),
2135
2269
  history: [],
2136
2270
  checkpoints: [],
@@ -2877,6 +3011,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2877
3011
 
2878
3012
  const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
2879
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
+
2880
3125
  const hooks = {
2881
3126
  config: async (config) => {
2882
3127
  applyNativeGoalConfig(config, {
@@ -2885,6 +3130,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2885
3130
  })
2886
3131
  if (pluginOptions.completionAudit) verifierRegistrationReady = true
2887
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
+ },
2888
3163
  "tool.execute.before": async (input) => {
2889
3164
  const sessionID = input?.sessionID
2890
3165
  if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
@@ -2980,11 +3255,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2980
3255
  output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
2981
3256
  return
2982
3257
  }
3258
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
2983
3259
  goal.stopped = true
2984
3260
  goal.stopReason = "paused"
2985
3261
  goal.lastStatus = "Goal paused."
3262
+ goal.continuationClaim = null
3263
+ activeContinues.delete(sessionID)
2986
3264
  pushHistory(goal, "paused", "User paused the active goal.")
2987
3265
  await persist()
3266
+ await abortAcceptedContinuation(sessionID)
2988
3267
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
2989
3268
  return
2990
3269
  }
@@ -3046,6 +3325,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3046
3325
  goal.noProgressTurns = 0
3047
3326
  goal.noToolCallTurns = 0
3048
3327
  goal.formatFailures = 0
3328
+ goal.continuationClaim = null
3049
3329
  goal.lastStatus = "Goal objective updated."
3050
3330
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3051
3331
  await persist()
@@ -3319,17 +3599,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3319
3599
  },
3320
3600
 
3321
3601
  event: async ({ event }) => {
3322
- if (isAbortErrorEvent(event)) {
3602
+ if (event?.type === "session.status") {
3323
3603
  const sessionID = getSessionID(event)
3324
- const goal = goalStates.get(sessionID)
3325
- if (!goal) return
3326
- currentRuntime().continuationControllers.get(sessionID)?.abort()
3327
- goal.stopped = true
3328
- goal.stopReason = "user interrupted"
3329
- goal.lastStatus = `Goal paused after user interruption. Run /${commandName} resume to continue.`
3330
- pushHistory(goal, "paused", "Paused after OpenCode reported that the user interrupted the active turn.")
3331
- activeContinues.delete(sessionID)
3332
- 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
+ })
3333
3629
  return
3334
3630
  }
3335
3631
 
@@ -3405,6 +3701,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3405
3701
  if (!isIdleEvent(event)) return
3406
3702
 
3407
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
+ }
3408
3710
  currentRuntime().readOnlyCommandGuards.delete(sessionID)
3409
3711
  const eventID = typeof event?.id === "string" ? event.id : ""
3410
3712
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
@@ -3424,6 +3726,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3424
3726
 
3425
3727
  const continueToken = randomUUID()
3426
3728
  const continueController = new AbortController()
3729
+ let claimedSourceAssistantMessageID = ""
3427
3730
  activeContinues.set(sessionID, continueToken)
3428
3731
  currentRuntime().continuationControllers.set(sessionID, continueController)
3429
3732
  try {
@@ -3435,9 +3738,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3435
3738
  : []
3436
3739
  const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
3437
3740
  if (!activeGoalAfterMessages) return
3741
+ if (!activeGoalAfterMessages.executionContext) {
3742
+ activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
3743
+ }
3438
3744
 
3439
3745
  const latestAssistant = findLatestAssistantMessage(messages)
3440
- const latestAssistantID = latestAssistant?.info?.id || ""
3746
+ const latestAssistantID = messageID(latestAssistant)
3441
3747
  const latestText = getText(latestAssistant?.parts)
3442
3748
  const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
3443
3749
  const previousAssistantText = activeGoalAfterMessages.lastAssistantText
@@ -3457,16 +3763,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3457
3763
  // since the last auto-continue, stop driving the loop and defer to the
3458
3764
  // human. They can /goal resume to hand control back to the plugin.
3459
3765
  if (userInterventionDetected(messages, activeGoalAfterMessages)) {
3460
- activeGoalAfterMessages.stopped = true
3461
- activeGoalAfterMessages.stopReason = "user intervention"
3462
- activeGoalAfterMessages.lastStatus =
3463
- `Auto-continue paused: you sent a new message, so the latest instruction wins. Run /${commandName} resume to continue the goal.`
3464
- pushHistory(
3465
- activeGoalAfterMessages,
3466
- "paused",
3467
- "Paused auto-continue after a real user message arrived; latest instruction wins.",
3468
- )
3469
- 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
+ ) {
3470
3780
  return
3471
3781
  }
3472
3782
 
@@ -3607,14 +3917,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3607
3917
  const limitReason = stopReason(activeGoalAfterMessages)
3608
3918
  if (limitReason) {
3609
3919
  if (!activeGoalAfterMessages.budgetWrapupSent) {
3610
- activeGoalAfterMessages.budgetWrapupSent = true
3611
- activeGoalAfterMessages.stopped = true
3612
- activeGoalAfterMessages.stopReason = limitReason
3613
- activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
3614
- pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
3615
- await sessionApi.promptAsync(sessionID, {
3616
- parts: [makeContinuationPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))],
3617
- })
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
+ }
3618
3949
  } else {
3619
3950
  activeGoalAfterMessages.stopped = true
3620
3951
  activeGoalAfterMessages.stopReason = limitReason
@@ -3736,6 +4067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3736
4067
  }
3737
4068
 
3738
4069
  const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
4070
+ let cooldownWaited = false
3739
4071
  if (
3740
4072
  activeGoalAfterMessages.lastContinueAt &&
3741
4073
  elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
@@ -3745,10 +4077,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3745
4077
  continueController.signal,
3746
4078
  )
3747
4079
  if (!delayCompleted) return
4080
+ cooldownWaited = true
3748
4081
  }
3749
4082
 
3750
- const activeGoalBeforePrompt = activeGoal(sessionID, goalID, runID)
4083
+ const activeGoalBeforePrompt = await claimContinuationSource(
4084
+ sessionID,
4085
+ goalID,
4086
+ runID,
4087
+ messages,
4088
+ { refreshMessages: cooldownWaited },
4089
+ )
3751
4090
  if (!activeGoalBeforePrompt) return
4091
+ claimedSourceAssistantMessageID =
4092
+ activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
3752
4093
 
3753
4094
  const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
3754
4095
  if (budgetWrapup) {
@@ -3805,22 +4146,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3805
4146
  }
3806
4147
  }
3807
4148
 
3808
- const response = await sessionApi.promptAsync(sessionID, {
3809
- parts: [
3810
- makeContinuationPart(
3811
- buildContinueMessage(activeGoalBeforePrompt, {
3812
- budgetWrapup,
3813
- completionUnverified,
3814
- blockerUnstated,
3815
- }),
3816
- ),
3817
- ],
3818
- })
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
+ }
3819
4167
 
3820
4168
  if (response.error) {
3821
4169
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3822
4170
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
3823
- if (activeGoalAfterPrompt) {
4171
+ if (
4172
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4173
+ claimedSourceAssistantMessageID
4174
+ ) {
4175
+ activeGoalAfterPrompt.continuationClaim = null
3824
4176
  activeGoalAfterPrompt.promptFailures += 1
3825
4177
  activeGoalAfterPrompt.lastStatus = message
3826
4178
  pushHistory(activeGoalAfterPrompt, "error", message)
@@ -3833,7 +4185,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3833
4185
  await logPluginError(client, message, response.error)
3834
4186
  } else {
3835
4187
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3836
- if (activeGoalAfterPrompt) {
4188
+ if (
4189
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4190
+ claimedSourceAssistantMessageID
4191
+ ) {
3837
4192
  // Decrement rather than reset: an alternating error/success pattern
3838
4193
  // should still accumulate toward the circuit-breaker cap over time,
3839
4194
  // matching the formatFailures approach for the same reason.
@@ -3851,6 +4206,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3851
4206
  } catch (error) {
3852
4207
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
3853
4208
  if (activeGoalAfterError) {
4209
+ if (
4210
+ claimedSourceAssistantMessageID &&
4211
+ activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
4212
+ claimedSourceAssistantMessageID
4213
+ ) {
4214
+ activeGoalAfterError.continuationClaim = null
4215
+ }
3854
4216
  activeGoalAfterError.promptFailures += 1
3855
4217
  const message = `Auto-continue failed: ${error?.message || error}`
3856
4218
  activeGoalAfterError.lastStatus = message
@@ -3864,6 +4226,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3864
4226
  }
3865
4227
  await logPluginError(client, "Auto-continue failed", error)
3866
4228
  } finally {
4229
+ currentRuntime().promptInFlightSessions.delete(sessionID)
3867
4230
  // Only delete our own entry. If cleanupGoal already removed it (because
3868
4231
  // the goal completed) and a new handler has since set a fresh token,
3869
4232
  // we must not clobber the new handler's guard.