opencode-goal-plugin 0.6.3 → 0.6.5

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.5 — 2026-07-12
4
+
5
+ - Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior.
6
+
7
+ ## 0.6.4 — 2026-07-12
8
+
9
+ - 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.
10
+ - 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
11
 
5
12
  ## 0.6.3 — 2026-07-11
6
13
 
package/README.md CHANGED
@@ -36,15 +36,16 @@ surface and versioning expectations.
36
36
 
37
37
  ### OpenCode version compatibility
38
38
 
39
- Manually tested via the OpenCode TUI (`tmux` + real provider credentials, no mocks), verified against the plugin's own persisted state rather than terminal display alone:
39
+ Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
40
40
 
41
41
  | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Hook Output Display |
42
42
  |---|---|---|---|---|---|
43
+ | 1.17.15 | opencode (`deepseek-v4-flash-free`) | ✅ Canonical tool | ✅ Checkpoint + idle continuation | ✅ Structured `goal_complete` claim | ⚠️ Command text routed to model; mutation guard verified |
43
44
  | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
44
45
  | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
45
46
  | 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
46
47
 
47
- `/goal status` and auto-continue are graded on **state correctness** (verified directly against the plugin's persisted state file: correct limits parsed, correct turn/stop accounting, correct completion detection) not on what's rendered in the terminal, since that's tracked separately as Hook Output Display.
48
+ `/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
48
49
 
49
50
  **Note:** Hook output display depends on OpenCode version — on 1.17.15, `command.execute.before`'s `output.parts` text is not rendered in the TUI for any provider tested; the raw command argument is instead routed to the model as a normal chat turn (see [Limitations](#limitations)). State mutations always work regardless of display: goal creation, flag parsing, auto-continue, limit enforcement, and evidence-gated completion detection were all verified correct via the persisted state file in every combination above. Re-test against your own OpenCode build before relying on unattended runs, and see [`docs/providers.md`](docs/providers.md) for the full per-model marker-compliance notes.
50
51
 
@@ -89,7 +90,7 @@ Add success criteria, constraints / non-goals, and a mode:
89
90
  /goal ship the release --success "tests pass and changelog updated" --constraints "do not touch the public API" --mode ordered
90
91
  ```
91
92
 
92
- `--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered` (alias `sisyphus`); `ordered` asks the assistant to work through the objective as a strict sequence. Multi-word values must be quoted.
93
+ `--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered`; `ordered` tells the assistant to preserve step order inside one objective. To queue distinct objectives that auto-promote one at a time, use `/goal sequence`. Multi-word values must be quoted.
93
94
 
94
95
  Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, given a non-positive integer, or (for `--mode`) an unrecognized mode, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text.
95
96
 
@@ -125,7 +126,7 @@ Pause without clearing the active goal:
125
126
  /goal pause
126
127
  ```
127
128
 
128
- Clear the active goal:
129
+ Clear all live goals in the current session and discard their saved status:
129
130
 
130
131
  ```
131
132
  /goal clear
@@ -155,14 +156,14 @@ A session can hold more than one goal. `/goal <condition>` replaces the focused
155
156
  /goal focus 1
156
157
  ```
157
158
 
158
- `/goal list` shows the numbered live goals (which is focused, which are backgrounded) and a per-session archive of completed/cleared goals so they stay readable. `/goal focus <number>` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart.
159
+ `/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus <number>` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart.
159
160
 
160
- #### Ordered (sisyphus) sequences
161
+ #### Ordered sequences
161
162
 
162
- `/goal sisyphus` sets up a strict execution sequence: separate the objectives with `;` or newlines, and the plugin runs them one at a time, auto-focusing the next as soon as the current one completes.
163
+ `/goal sequence` sets up a strict execution queue: separate objectives with `;` or newlines, and the plugin runs them one at a time, focusing the next as soon as the current one completes.
163
164
 
164
165
  ```
165
- /goal sisyphus build the parser; write the tests; ship the release
166
+ /goal sequence build the parser; write the tests; ship the release
166
167
  ```
167
168
 
168
169
  The first goal is focused and the rest are queued. `/goal list` marks the session as ordered. Auto-promotion stops when the sequence is exhausted; `/goal clear` ends the sequence.
@@ -187,16 +188,16 @@ With success criteria, constraints, and a token budget shorthand:
187
188
  An ordered sequence, run as a strict pipeline:
188
189
 
189
190
  ```
190
- /goal sisyphus build the parser; write the tests; ship the release
191
+ /goal sequence build the parser; write the tests; ship the release
191
192
  ```
192
193
 
193
194
  ## How it works
194
195
 
195
196
  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.
197
+ 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
198
  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
199
  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.
200
+ 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
201
 
201
202
  ## Completion markers
202
203
 
@@ -272,7 +273,7 @@ Override any limit for a single goal:
272
273
  | `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
273
274
  | `--success <text>` | Success criteria that define when the goal is satisfied (quote multi-word text) |
274
275
  | `--constraints <text>` | Constraints / non-goals to respect (alias `--non-goals`) |
275
- | `--mode <normal\|ordered>` | Execution mode; `ordered` (alias `sisyphus`) asks for a strict sequence |
276
+ | `--mode <normal\|ordered>` | Prompt mode for one goal; `ordered` preserves step order inside its objective |
276
277
  | `--no-tool-turns <n>` | Consecutive tool-free continuation turns before pausing |
277
278
 
278
279
  Examples:
@@ -343,7 +344,7 @@ Registered tools:
343
344
 
344
345
  `goal_set` and `set_goal` are explicitly constrained to user-requested goals. `goal_complete` accepts a structured claim: a required non-empty `summary`, plus optional criterion/evidence pairs, checks (`passed`, `failed`, or `not-run`), changed files, and known limitations. Failed checks and empty criterion evidence are rejected before archival; accepted claims are serialized deterministically for the configured completion auditor. The legacy `update_goal` tool retains its string `evidence` field for compatibility.
345
346
 
346
- These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered (sisyphus) sequence auto-promotes the next.
347
+ These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered sequence auto-promotes the next.
347
348
 
348
349
  > Integration note: the tool execute-context shape (`ctx.sessionID`) and the `tool.schema` surface follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but the live registration should be confirmed against a real OpenCode run (see the smoke-test checklist).
349
350
 
package/index.d.ts CHANGED
@@ -48,7 +48,7 @@ export interface GoalAuditSnapshot {
48
48
  condition: string
49
49
  successCriteria: string
50
50
  constraints: string
51
- mode: "normal" | "sisyphus"
51
+ mode: "normal" | "ordered"
52
52
  sessionID: string
53
53
  turnCount: number
54
54
  startedAt: number
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.5",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -74,7 +74,7 @@ const DEFAULT_OPTIONS = {
74
74
  // handler drives and that the system-prompt transform injects. `sessionGoals`
75
75
  // is the full registry of live goals per session (focused + backgrounded);
76
76
  // the focused goal is the same object reference held in both. `sessionArchive`
77
- // keeps a capped list of completed/cleared goals so they stay readable.
77
+ // keeps a capped list of achieved goals so completed work stays readable.
78
78
  function createRuntimeState() {
79
79
  return {
80
80
  goalStates: new Map(),
@@ -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,
@@ -126,7 +129,7 @@ function runtimeCollection(name) {
126
129
  const goalStates = runtimeCollection("goalStates")
127
130
  const sessionGoals = runtimeCollection("sessionGoals")
128
131
  const sessionArchive = runtimeCollection("sessionArchive")
129
- // Sessions running an ordered (sisyphus) sequence: when the focused goal
132
+ // Sessions running an ordered sequence: when the focused goal
130
133
  // completes, the next live goal (in creation order) is auto-promoted to focus
131
134
  // so the sequence advances on its own.
132
135
  const sessionOrdered = runtimeCollection("sessionOrdered")
@@ -144,6 +147,9 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
144
147
  const activeContinues = runtimeCollection("activeContinues")
145
148
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
146
149
  const PAUSE_COMMANDS = new Set(["pause"])
150
+ // `sequence` is canonical. The former public spelling remains accepted at
151
+ // the parser boundary so existing scripts do not break.
152
+ const SEQUENCE_COMMANDS = ["sequence", "sisyphus"]
147
153
  const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
148
154
  const GOAL_FLAG_SPECS = {
149
155
  "--max-turns": {
@@ -208,8 +214,8 @@ function messageHasToolCall(message) {
208
214
 
209
215
  const GOAL_MODES = new Set(["normal", "ordered"])
210
216
 
211
- // Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered`
212
- // signals a strict execution sequence; `sisyphus` is accepted as an alias.
217
+ // Goal mode: normal vs ordered. The former public spelling remains accepted
218
+ // as an input alias, while stored state and output always use `ordered`.
213
219
  // Returns the canonical mode or null when unrecognized.
214
220
  function normalizeMode(value) {
215
221
  const normalized = String(value || "").trim().toLowerCase()
@@ -242,7 +248,13 @@ function makeContinuationPart(text) {
242
248
  }
243
249
 
244
250
  function getSessionID(event) {
245
- return event?.properties?.sessionID || event?.properties?.info?.sessionID || null
251
+ return (
252
+ event?.properties?.sessionID ||
253
+ event?.properties?.info?.sessionID ||
254
+ event?.data?.sessionID ||
255
+ event?.data?.info?.sessionID ||
256
+ null
257
+ )
246
258
  }
247
259
 
248
260
  function isIdleEvent(event) {
@@ -252,12 +264,76 @@ function isIdleEvent(event) {
252
264
  )
253
265
  }
254
266
 
255
- function isAbortErrorEvent(event) {
256
- if (event?.type !== "session.error") return false
257
- const error = event?.properties?.error
267
+ function normalizeExecutionContext(value) {
268
+ if (!isPlainObject(value)) return null
269
+ const model = isPlainObject(value.model) ? value.model : {}
270
+ const boundedContextText = (candidate) => {
271
+ if (typeof candidate !== "string") return ""
272
+ const normalized = candidate.trim()
273
+ return normalized.length <= MAX_GOAL_META_LENGTH ? normalized : ""
274
+ }
275
+ const agent = boundedContextText(value.agent)
276
+ const providerID = boundedContextText(model.providerID)
277
+ const modelID =
278
+ boundedContextText(model.modelID) || boundedContextText(model.id)
279
+ const variantValue = value.variant ?? model.variant
280
+ const variant = boundedContextText(variantValue)
281
+ if (!agent && !(providerID && modelID) && !variant) return null
282
+ return {
283
+ ...(agent ? { agent } : {}),
284
+ ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
285
+ ...(variant ? { variant } : {}),
286
+ }
287
+ }
288
+
289
+ function continuationContextInput(goal) {
290
+ const context = normalizeExecutionContext(goal?.executionContext)
291
+ return context ? { ...context } : {}
292
+ }
293
+
294
+ function isPlanAgent(agent) {
295
+ return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
296
+ }
297
+
298
+ function terminalEvent(event) {
299
+ const permissionReply = String(
300
+ event?.properties?.reply ??
301
+ event?.properties?.response ??
302
+ event?.data?.reply ??
303
+ event?.data?.response ??
304
+ "",
305
+ )
306
+ if (event?.type === "permission.replied" && /^(?:reject(?:ed)?|deny|denied)$/i.test(permissionReply)) {
307
+ return {
308
+ sessionID: getSessionID(event),
309
+ stopReason: "permission rejected",
310
+ status: "Goal paused after a permission request was rejected.",
311
+ history: "Paused after OpenCode reported a rejected permission request.",
312
+ }
313
+ }
314
+
315
+ let error = null
316
+ if (event?.type === "session.error") {
317
+ error = event?.properties?.error || event?.data?.error
318
+ } else if (event?.type === "message.updated") {
319
+ error = messageInfoFromEvent(event)?.error
320
+ }
321
+ if (!error) return null
322
+
258
323
  const name = String(error?.name || error?.data?.name || "")
259
324
  const message = String(error?.message || error?.data?.message || "")
260
- return name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
325
+ const aborted = name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
326
+ const summary = summarizeText(`${name}${message ? `: ${message}` : ""}`, 240) || "unknown provider error"
327
+ return {
328
+ sessionID: getSessionID(event) || messageSessionID(messageInfoFromEvent(event)),
329
+ stopReason: aborted ? "user interrupted" : "provider error",
330
+ status: aborted
331
+ ? "Goal paused after user interruption."
332
+ : `Goal paused after a terminal provider error: ${summary}`,
333
+ history: aborted
334
+ ? "Paused after OpenCode reported that the active turn was aborted."
335
+ : `Paused after OpenCode reported a terminal provider error: ${summary}`,
336
+ }
261
337
  }
262
338
 
263
339
  function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
@@ -654,7 +730,7 @@ function archiveSessionResult(sessionID, result) {
654
730
  sessionArchive.set(sessionID, list.slice(-MAX_ARCHIVED_PER_SESSION))
655
731
  }
656
732
 
657
- // Advance an ordered (sisyphus) sequence: focus the next live goal in creation
733
+ // Advance an ordered sequence: focus the next live goal in creation
658
734
  // order, clearing any backgrounded state so the idle handler drives it. Returns
659
735
  // the promoted goal, or null when the sequence is exhausted (which also clears
660
736
  // the session's ordered flag).
@@ -670,7 +746,7 @@ function promoteNextOrderedGoal(sessionID) {
670
746
  resumeGoalClock(next)
671
747
  next.skipNextTerminalCheck = true
672
748
  next.lastStatus = "Promoted as the next ordered goal."
673
- pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered (sisyphus) sequence.")
749
+ pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered sequence.")
674
750
  focusGoal(sessionID, next)
675
751
  return next
676
752
  }
@@ -706,7 +782,10 @@ function clearRuntimeState() {
706
782
  seenOutputTokens.clear()
707
783
  activeContinues.clear()
708
784
  runtime.continuationControllers.clear()
785
+ runtime.promptInFlightSessions.clear()
709
786
  runtime.seenIdleEventIDs.clear()
787
+ runtime.sessionStatuses.clear()
788
+ runtime.sessionExecutionContexts.clear()
710
789
  runtime.readOnlyCommandGuards.clear()
711
790
  }
712
791
 
@@ -805,6 +884,7 @@ function resetGoalBudget(goal) {
805
884
  goal.promptFailures = 0
806
885
  goal.formatFailures = 0
807
886
  goal.lastAssistantMessageID = ""
887
+ goal.continuationClaim = null
808
888
  goal.skipNextTerminalCheck = false
809
889
  goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
810
890
  }
@@ -1109,6 +1189,18 @@ function normalizePersistedGoal(rawGoal) {
1109
1189
  stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
1110
1190
  promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
1111
1191
  formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
1192
+ executionContext: normalizeExecutionContext(rawGoal.executionContext),
1193
+ continuationClaim:
1194
+ isPlainObject(rawGoal.continuationClaim) &&
1195
+ typeof rawGoal.continuationClaim.runId === "string" &&
1196
+ rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
1197
+ typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
1198
+ rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
1199
+ ? {
1200
+ runId: rawGoal.continuationClaim.runId,
1201
+ sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
1202
+ }
1203
+ : null,
1112
1204
  messageIDs: Array.isArray(rawGoal.messageIDs)
1113
1205
  ? rawGoal.messageIDs.slice(-MAX_MESSAGE_IDS_PER_GOAL).filter((messageID) => typeof messageID === "string" && messageID.length <= MAX_GOAL_META_LENGTH)
1114
1206
  : [],
@@ -1181,6 +1273,9 @@ function deserializeGoal(goal) {
1181
1273
  "Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
1182
1274
  )
1183
1275
  }
1276
+ // Recovered goals always require an explicit resume, which starts a fresh
1277
+ // execution epoch and makes any pre-crash continuation claim obsolete.
1278
+ hydrated.continuationClaim = null
1184
1279
 
1185
1280
  return hydrated
1186
1281
  }
@@ -1898,7 +1993,8 @@ function messageRole(message) {
1898
1993
  }
1899
1994
 
1900
1995
  function messageID(message) {
1901
- return message?.info?.id || message?.id || ""
1996
+ const id = message?.info?.id || message?.id || ""
1997
+ return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : ""
1902
1998
  }
1903
1999
 
1904
2000
  function messageSessionID(message) {
@@ -1986,6 +2082,9 @@ function messageInfoFromEvent(event) {
1986
2082
  event?.properties?.info,
1987
2083
  event?.properties?.message?.info,
1988
2084
  event?.properties?.message,
2085
+ event?.data?.info,
2086
+ event?.data?.message?.info,
2087
+ event?.data?.message,
1989
2088
  ]
1990
2089
  return candidates.find(isPlainObject) || null
1991
2090
  }
@@ -2049,6 +2148,35 @@ function findLatestAssistantMessage(messages) {
2049
2148
  return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
2050
2149
  }
2051
2150
 
2151
+ function findLatestExecutionContext(messages) {
2152
+ for (const message of [...(messages || [])].reverse()) {
2153
+ if (messageRole(message) !== "user") continue
2154
+ const info = isPlainObject(message?.info) ? message.info : message
2155
+ const context = normalizeExecutionContext(info)
2156
+ if (context) return context
2157
+ }
2158
+ return null
2159
+ }
2160
+
2161
+ function continuationSnapshot(messages) {
2162
+ const list = Array.isArray(messages) ? messages : []
2163
+ const latestAssistant = findLatestAssistantMessage(list)
2164
+ const latestRealUser = [...list]
2165
+ .reverse()
2166
+ .find((message) => messageRole(message) === "user" && !isPluginContinuationMessage(message))
2167
+ const latestRelevant = [...list]
2168
+ .reverse()
2169
+ .find((message) =>
2170
+ (messageRole(message) === "assistant" || messageRole(message) === "user") &&
2171
+ !isPluginContinuationMessage(message),
2172
+ )
2173
+ return {
2174
+ latestAssistantID: messageID(latestAssistant),
2175
+ latestRealUserMessageID: messageID(latestRealUser),
2176
+ latestRelevantMessageID: messageID(latestRelevant),
2177
+ }
2178
+ }
2179
+
2052
2180
  // The plugin drives auto-continue by sending its own prompts via promptAsync,
2053
2181
  // which appear in the session as user-role messages. Every such prompt is
2054
2182
  // framed inside <goal_continuation>, so a user message containing that marker
@@ -2136,6 +2264,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2136
2264
  stopReason: "",
2137
2265
  promptFailures: 0,
2138
2266
  formatFailures: 0,
2267
+ executionContext: normalizeExecutionContext(
2268
+ meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
2269
+ ),
2270
+ continuationClaim: null,
2139
2271
  messageIDs: new Set(),
2140
2272
  history: [],
2141
2273
  checkpoints: [],
@@ -2334,7 +2466,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2334
2466
  const ordered = sessionOrdered.has(sessionID)
2335
2467
  rememberGoalResult(sessionID, goal, "achieved", "", evidence)
2336
2468
  cleanupGoal(sessionID)
2337
- // Advance an ordered (sisyphus) sequence just like the marker path does.
2469
+ // Advance an ordered sequence just like the marker path does.
2338
2470
  if (ordered) promoteNextOrderedGoal(sessionID)
2339
2471
  const durable = await persistFinal("completion", ledgerDurable)
2340
2472
  if (durable === false) {
@@ -2578,7 +2710,7 @@ function formatGoalList(sessionID, commandName = "goal") {
2578
2710
 
2579
2711
  const lines = []
2580
2712
  if (goals.length) {
2581
- lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered (sisyphus)" : ""}:`)
2713
+ lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
2582
2714
  goals.forEach((goal, index) => {
2583
2715
  const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
2584
2716
  const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
@@ -2882,6 +3014,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2882
3014
 
2883
3015
  const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
2884
3016
 
3017
+ const abortAcceptedContinuation = async (sessionID) => {
3018
+ const runtimeState = currentRuntime()
3019
+ runtimeState.continuationControllers.get(sessionID)?.abort()
3020
+ if (
3021
+ !runtimeState.promptInFlightSessions.has(sessionID) ||
3022
+ typeof client?.session?.abort !== "function"
3023
+ ) {
3024
+ return
3025
+ }
3026
+ try {
3027
+ await sessionApi.abort(sessionID)
3028
+ } catch (error) {
3029
+ await logPluginError(client, "Failed to abort an accepted auto-continue after intervention", error)
3030
+ }
3031
+ }
3032
+
3033
+ const pauseActiveGoal = async (
3034
+ sessionID,
3035
+ { stopReason: reason, status, history, abortAccepted = false },
3036
+ ) => {
3037
+ const goal = goalStates.get(sessionID)
3038
+ if (!goal) return false
3039
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
3040
+ goal.stopped = true
3041
+ goal.stopReason = reason
3042
+ goal.lastStatus = `${status} Run /${commandName} resume to continue.`
3043
+ goal.continuationClaim = null
3044
+ pushHistory(goal, "paused", history)
3045
+ activeContinues.delete(sessionID)
3046
+ await persist()
3047
+ if (abortAccepted) await abortAcceptedContinuation(sessionID)
3048
+ return true
3049
+ }
3050
+
3051
+ const claimContinuationSource = async (
3052
+ sessionID,
3053
+ goalID,
3054
+ runID,
3055
+ baselineMessages,
3056
+ { refreshMessages = false } = {},
3057
+ ) => {
3058
+ const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
3059
+ if (!goalBeforeRefresh) return null
3060
+ const hostMessages = refreshMessages
3061
+ ? await sessionApi.messages(sessionID, {
3062
+ limit: goalBeforeRefresh.options.maxRecentMessages,
3063
+ })
3064
+ : baselineMessages
3065
+ const goal = activeGoal(sessionID, goalID, runID)
3066
+ if (!goal) return null
3067
+ const messages = Array.isArray(hostMessages)
3068
+ ? hostMessages.slice(-goal.options.maxRecentMessages)
3069
+ : []
3070
+ const baseline = continuationSnapshot(baselineMessages)
3071
+ const refreshed = continuationSnapshot(messages)
3072
+
3073
+ if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
3074
+
3075
+ const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
3076
+ if (isPlanAgent(currentContext?.agent)) {
3077
+ await pauseActiveGoal(sessionID, {
3078
+ stopReason: "plan agent active",
3079
+ status: "Auto-continue paused because the active agent switched to Plan.",
3080
+ history: "Paused before auto-continue because the active session agent switched to Plan.",
3081
+ })
3082
+ return null
3083
+ }
3084
+
3085
+ const newHumanMessage =
3086
+ refreshed.latestRealUserMessageID &&
3087
+ refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
3088
+ if (newHumanMessage || userInterventionDetected(messages, goal)) {
3089
+ await pauseActiveGoal(sessionID, {
3090
+ stopReason: "user intervention",
3091
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3092
+ history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
3093
+ })
3094
+ return null
3095
+ }
3096
+
3097
+ if (
3098
+ refreshed.latestAssistantID !== baseline.latestAssistantID ||
3099
+ refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
3100
+ ) {
3101
+ return null
3102
+ }
3103
+
3104
+ if (!goal.executionContext) {
3105
+ goal.executionContext = findLatestExecutionContext(messages)
3106
+ }
3107
+ const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
3108
+ if (
3109
+ goal.continuationClaim?.runId === runID &&
3110
+ goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
3111
+ ) {
3112
+ return null
3113
+ }
3114
+
3115
+ goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
3116
+ const claimPersisted = await persist()
3117
+ if (!claimPersisted && persistenceOptions.persistState) {
3118
+ goal.continuationClaim = null
3119
+ goal.stopped = true
3120
+ goal.stopReason = "continuation claim persistence failed"
3121
+ goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
3122
+ pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
3123
+ return null
3124
+ }
3125
+ return goal
3126
+ }
3127
+
2885
3128
  const hooks = {
2886
3129
  config: async (config) => {
2887
3130
  applyNativeGoalConfig(config, {
@@ -2890,6 +3133,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2890
3133
  })
2891
3134
  if (pluginOptions.completionAudit) verifierRegistrationReady = true
2892
3135
  },
3136
+ "chat.params": async (input) => {
3137
+ if (!input?.sessionID) return
3138
+ const context = normalizeExecutionContext({
3139
+ agent: input.agent,
3140
+ model: input.model,
3141
+ variant: input?.message?.model?.variant,
3142
+ })
3143
+ if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
3144
+ },
3145
+ "chat.message": async (input, output) => {
3146
+ const sessionID = input?.sessionID
3147
+ if (!sessionID) return
3148
+ const context = normalizeExecutionContext(input)
3149
+ if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3150
+
3151
+ const message = { role: "user", parts: Array.isArray(output?.parts) ? output.parts : [] }
3152
+ if (isPluginContinuationMessage(message)) return
3153
+ const text = getText(message.parts)
3154
+ const commandPrefix = `/${commandName}`
3155
+ if (text === commandPrefix || text.startsWith(`${commandPrefix} `)) return
3156
+
3157
+ const goal = goalStates.get(sessionID)
3158
+ if (!goal || goal.stopped) return
3159
+ await pauseActiveGoal(sessionID, {
3160
+ stopReason: "user intervention",
3161
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3162
+ history: "Paused immediately when a new human message arrived; latest instruction wins.",
3163
+ abortAccepted: true,
3164
+ })
3165
+ },
2893
3166
  "tool.execute.before": async (input) => {
2894
3167
  const sessionID = input?.sessionID
2895
3168
  if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
@@ -2985,11 +3258,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2985
3258
  output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
2986
3259
  return
2987
3260
  }
3261
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
2988
3262
  goal.stopped = true
2989
3263
  goal.stopReason = "paused"
2990
3264
  goal.lastStatus = "Goal paused."
3265
+ goal.continuationClaim = null
3266
+ activeContinues.delete(sessionID)
2991
3267
  pushHistory(goal, "paused", "User paused the active goal.")
2992
3268
  await persist()
3269
+ await abortAcceptedContinuation(sessionID)
2993
3270
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
2994
3271
  return
2995
3272
  }
@@ -3051,6 +3328,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3051
3328
  goal.noProgressTurns = 0
3052
3329
  goal.noToolCallTurns = 0
3053
3330
  goal.formatFailures = 0
3331
+ goal.continuationClaim = null
3054
3332
  goal.lastStatus = "Goal objective updated."
3055
3333
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3056
3334
  await persist()
@@ -3072,8 +3350,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3072
3350
  return
3073
3351
  }
3074
3352
 
3075
- if (args === "sisyphus" || args.toLowerCase().startsWith("sisyphus ")) {
3076
- const rest = args.slice("sisyphus".length).trim()
3353
+ const sequenceCommand = SEQUENCE_COMMANDS.find(
3354
+ (command) => args.toLowerCase() === command || args.toLowerCase().startsWith(`${command} `),
3355
+ )
3356
+ if (sequenceCommand) {
3357
+ const rest = args.slice(sequenceCommand.length).trim()
3077
3358
  const objectives = rest
3078
3359
  .split(/\n|;/)
3079
3360
  .map((part) => stripWrappingQuotes(part.trim()))
@@ -3081,7 +3362,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3081
3362
  if (!objectives.length) {
3082
3363
  output.parts = [
3083
3364
  makeTextPart(
3084
- `No objectives provided. Use \`/${commandName} sisyphus <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
3365
+ `No objectives provided. Use \`/${commandName} sequence <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
3085
3366
  ),
3086
3367
  ]
3087
3368
  return
@@ -3125,7 +3406,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3125
3406
  pushHistory(
3126
3407
  created,
3127
3408
  "set",
3128
- `Ordered goal ${index + 1}/${objectives.length} created (sisyphus sequence).`,
3409
+ `Ordered goal ${index + 1}/${objectives.length} created.`,
3129
3410
  )
3130
3411
  registerSessionGoal(created)
3131
3412
  })
@@ -3135,7 +3416,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3135
3416
  output.parts = [
3136
3417
  makeTextPart(
3137
3418
  [
3138
- `Started an ordered sequence of ${objectives.length} goal(s) (sisyphus mode):`,
3419
+ `Started an ordered sequence of ${objectives.length} goal(s):`,
3139
3420
  ...objectives.map((objective, index) => `${index + 1}. ${objective}`),
3140
3421
  "",
3141
3422
  `Focused goal 1: ${firstGoal.condition}`,
@@ -3285,7 +3566,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3285
3566
  // Replace the focused goal (cleanupGoal discards it); backgrounded goals
3286
3567
  // for this session are preserved. Use `/goal add` to keep the current
3287
3568
  // goal and add another. Clear any ordered-sequence flag so the new
3288
- // standalone goal does not trigger sisyphus auto-promotion of old sequence
3569
+ // standalone goal does not trigger auto-promotion of the old sequence
3289
3570
  // goals that may still be in the registry (matches the agent setGoal path).
3290
3571
  sessionOrdered.delete(sessionID)
3291
3572
  cleanupGoal(sessionID)
@@ -3324,17 +3605,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3324
3605
  },
3325
3606
 
3326
3607
  event: async ({ event }) => {
3327
- if (isAbortErrorEvent(event)) {
3608
+ if (event?.type === "session.status") {
3328
3609
  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()
3610
+ const status = event?.properties?.status?.type || event?.data?.status?.type
3611
+ if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
3612
+ }
3613
+
3614
+ if (event?.type === "session.updated") {
3615
+ const sessionID = getSessionID(event)
3616
+ const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
3617
+ if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3618
+ }
3619
+
3620
+ if (event?.type === "message.updated") {
3621
+ const message = messageInfoFromEvent(event)
3622
+ if (messageRole(message) === "user") {
3623
+ const context = normalizeExecutionContext(message)
3624
+ const sessionID = messageSessionID(message) || getSessionID(event)
3625
+ if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3626
+ }
3627
+ }
3628
+
3629
+ const terminal = terminalEvent(event)
3630
+ if (terminal?.sessionID) {
3631
+ await pauseActiveGoal(terminal.sessionID, {
3632
+ ...terminal,
3633
+ abortAccepted: true,
3634
+ })
3338
3635
  return
3339
3636
  }
3340
3637
 
@@ -3410,6 +3707,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3410
3707
  if (!isIdleEvent(event)) return
3411
3708
 
3412
3709
  const sessionID = getSessionID(event)
3710
+ // Deprecated session.idle carries no status object but is itself an
3711
+ // authoritative idle signal. Current session.status events were recorded
3712
+ // above before entering this branch.
3713
+ if (event?.type === "session.idle") {
3714
+ currentRuntime().sessionStatuses.set(sessionID, "idle")
3715
+ }
3413
3716
  currentRuntime().readOnlyCommandGuards.delete(sessionID)
3414
3717
  const eventID = typeof event?.id === "string" ? event.id : ""
3415
3718
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
@@ -3429,6 +3732,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3429
3732
 
3430
3733
  const continueToken = randomUUID()
3431
3734
  const continueController = new AbortController()
3735
+ let claimedSourceAssistantMessageID = ""
3432
3736
  activeContinues.set(sessionID, continueToken)
3433
3737
  currentRuntime().continuationControllers.set(sessionID, continueController)
3434
3738
  try {
@@ -3440,9 +3744,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3440
3744
  : []
3441
3745
  const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
3442
3746
  if (!activeGoalAfterMessages) return
3747
+ if (!activeGoalAfterMessages.executionContext) {
3748
+ activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
3749
+ }
3443
3750
 
3444
3751
  const latestAssistant = findLatestAssistantMessage(messages)
3445
- const latestAssistantID = latestAssistant?.info?.id || ""
3752
+ const latestAssistantID = messageID(latestAssistant)
3446
3753
  const latestText = getText(latestAssistant?.parts)
3447
3754
  const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
3448
3755
  const previousAssistantText = activeGoalAfterMessages.lastAssistantText
@@ -3462,16 +3769,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3462
3769
  // since the last auto-continue, stop driving the loop and defer to the
3463
3770
  // human. They can /goal resume to hand control back to the plugin.
3464
3771
  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()
3772
+ await pauseActiveGoal(sessionID, {
3773
+ stopReason: "user intervention",
3774
+ status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
3775
+ history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
3776
+ })
3777
+ return
3778
+ }
3779
+
3780
+ const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
3781
+ if (
3782
+ activeGoalAfterMessages.continuationClaim?.runId === runID &&
3783
+ activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
3784
+ sourceAssistantMessageID
3785
+ ) {
3475
3786
  return
3476
3787
  }
3477
3788
 
@@ -3547,7 +3858,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3547
3858
  const ordered = sessionOrdered.has(sessionID)
3548
3859
  rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
3549
3860
  cleanupGoal(sessionID)
3550
- // Ordered (sisyphus) sequence: auto-promote the next goal so the
3861
+ // Ordered sequence: auto-promote the next goal so the
3551
3862
  // session keeps working through the sequence without manual /goal focus.
3552
3863
  if (ordered) {
3553
3864
  promoteNextOrderedGoal(sessionID)
@@ -3612,14 +3923,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3612
3923
  const limitReason = stopReason(activeGoalAfterMessages)
3613
3924
  if (limitReason) {
3614
3925
  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
- })
3926
+ const claimedGoal = await claimContinuationSource(
3927
+ sessionID,
3928
+ goalID,
3929
+ runID,
3930
+ messages,
3931
+ )
3932
+ if (!claimedGoal) return
3933
+ claimedSourceAssistantMessageID =
3934
+ claimedGoal.continuationClaim?.sourceAssistantMessageID || ""
3935
+ claimedGoal.budgetWrapupSent = true
3936
+ claimedGoal.stopped = true
3937
+ claimedGoal.stopReason = limitReason
3938
+ claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
3939
+ pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
3940
+ await persist()
3941
+ currentRuntime().promptInFlightSessions.add(sessionID)
3942
+ let response
3943
+ try {
3944
+ response = await sessionApi.promptAsync(sessionID, {
3945
+ ...continuationContextInput(claimedGoal),
3946
+ parts: [makeContinuationPart(buildContinueMessage(claimedGoal, { budgetWrapup: true }))],
3947
+ })
3948
+ } finally {
3949
+ currentRuntime().promptInFlightSessions.delete(sessionID)
3950
+ }
3951
+ if (response?.error) {
3952
+ claimedGoal.lastStatus = `${limitReason}; final handoff request failed: ${response.error.name || "unknown error"}.`
3953
+ pushHistory(claimedGoal, "error", claimedGoal.lastStatus)
3954
+ }
3623
3955
  } else {
3624
3956
  activeGoalAfterMessages.stopped = true
3625
3957
  activeGoalAfterMessages.stopReason = limitReason
@@ -3741,6 +4073,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3741
4073
  }
3742
4074
 
3743
4075
  const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
4076
+ let cooldownWaited = false
3744
4077
  if (
3745
4078
  activeGoalAfterMessages.lastContinueAt &&
3746
4079
  elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
@@ -3750,10 +4083,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3750
4083
  continueController.signal,
3751
4084
  )
3752
4085
  if (!delayCompleted) return
4086
+ cooldownWaited = true
3753
4087
  }
3754
4088
 
3755
- const activeGoalBeforePrompt = activeGoal(sessionID, goalID, runID)
4089
+ const activeGoalBeforePrompt = await claimContinuationSource(
4090
+ sessionID,
4091
+ goalID,
4092
+ runID,
4093
+ messages,
4094
+ { refreshMessages: cooldownWaited },
4095
+ )
3756
4096
  if (!activeGoalBeforePrompt) return
4097
+ claimedSourceAssistantMessageID =
4098
+ activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
3757
4099
 
3758
4100
  const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
3759
4101
  if (budgetWrapup) {
@@ -3810,22 +4152,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3810
4152
  }
3811
4153
  }
3812
4154
 
3813
- const response = await sessionApi.promptAsync(sessionID, {
3814
- parts: [
3815
- makeContinuationPart(
3816
- buildContinueMessage(activeGoalBeforePrompt, {
3817
- budgetWrapup,
3818
- completionUnverified,
3819
- blockerUnstated,
3820
- }),
3821
- ),
3822
- ],
3823
- })
4155
+ currentRuntime().promptInFlightSessions.add(sessionID)
4156
+ let response
4157
+ try {
4158
+ response = await sessionApi.promptAsync(sessionID, {
4159
+ ...continuationContextInput(activeGoalBeforePrompt),
4160
+ parts: [
4161
+ makeContinuationPart(
4162
+ buildContinueMessage(activeGoalBeforePrompt, {
4163
+ budgetWrapup,
4164
+ completionUnverified,
4165
+ blockerUnstated,
4166
+ }),
4167
+ ),
4168
+ ],
4169
+ })
4170
+ } finally {
4171
+ currentRuntime().promptInFlightSessions.delete(sessionID)
4172
+ }
3824
4173
 
3825
4174
  if (response.error) {
3826
4175
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3827
4176
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
3828
- if (activeGoalAfterPrompt) {
4177
+ if (
4178
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4179
+ claimedSourceAssistantMessageID
4180
+ ) {
4181
+ activeGoalAfterPrompt.continuationClaim = null
3829
4182
  activeGoalAfterPrompt.promptFailures += 1
3830
4183
  activeGoalAfterPrompt.lastStatus = message
3831
4184
  pushHistory(activeGoalAfterPrompt, "error", message)
@@ -3838,7 +4191,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3838
4191
  await logPluginError(client, message, response.error)
3839
4192
  } else {
3840
4193
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
3841
- if (activeGoalAfterPrompt) {
4194
+ if (
4195
+ activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
4196
+ claimedSourceAssistantMessageID
4197
+ ) {
3842
4198
  // Decrement rather than reset: an alternating error/success pattern
3843
4199
  // should still accumulate toward the circuit-breaker cap over time,
3844
4200
  // matching the formatFailures approach for the same reason.
@@ -3856,6 +4212,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3856
4212
  } catch (error) {
3857
4213
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
3858
4214
  if (activeGoalAfterError) {
4215
+ if (
4216
+ claimedSourceAssistantMessageID &&
4217
+ activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
4218
+ claimedSourceAssistantMessageID
4219
+ ) {
4220
+ activeGoalAfterError.continuationClaim = null
4221
+ }
3859
4222
  activeGoalAfterError.promptFailures += 1
3860
4223
  const message = `Auto-continue failed: ${error?.message || error}`
3861
4224
  activeGoalAfterError.lastStatus = message
@@ -3869,6 +4232,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3869
4232
  }
3870
4233
  await logPluginError(client, "Auto-continue failed", error)
3871
4234
  } finally {
4235
+ currentRuntime().promptInFlightSessions.delete(sessionID)
3872
4236
  // Only delete our own entry. If cleanupGoal already removed it (because
3873
4237
  // the goal completed) and a new handler has since set a fresh token,
3874
4238
  // we must not clobber the new handler's guard.