@prevalentware/opencode-goal-plugin 0.1.54 → 0.1.56

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/README.md CHANGED
@@ -259,6 +259,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po
259
259
 
260
260
  The plugin migrates version 1 state files to version 2 on the next write. Version 2 adds stable goal identities, the terminal `cancelled` status, and compact archives. Older plugin versions intentionally reject version 2 instead of silently dropping archived history on their next write; back up or isolate `OPENCODE_GOAL_STATE_PATH` before downgrading.
261
261
 
262
+ Planless version 3 files from the planning development build are also compatible: empty `plan: null` and `planRevision: 0` metadata survives reads, updates, and archival, and writes retain version 3. This prevents an otherwise valid shared state file from breaking ordinary OpenCode tool calls in sessions without a goal. Populated plans, nonzero planning revisions, and unknown future versions remain rejected without rewriting the file; use the corresponding planning build or an isolated `OPENCODE_GOAL_STATE_PATH` for those files.
263
+
262
264
  If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.
263
265
 
264
266
  If a non-empty state file contains only whitespace, a UTF-8 BOM, or NUL bytes after an interrupted write, the next mutation preserves its exact contents beside the state file as `goals.json.corrupt-<timestamp>-<uuid>` before writing recovered state. If another process replaces the state during recovery, the mutation refuses to overwrite that newer content. If the quarantine copy itself cannot be created, the plugin reports the failure and continues recovery rather than making every prompt fail indefinitely. OpenCode 1 also records the quarantine outcome and path through its application log so the data-loss event remains discoverable.
@@ -315,6 +317,13 @@ OpenCode plugin modules are target-specific. This package exports separate modul
315
317
  }
316
318
  ```
317
319
 
318
- Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery, while interruptions (user, shutdown, or superseded) cancel local timers without starting another turn or charging a prompt failure. Interrupted or non-transport-failed executions remain suppressed until the host starts a new execution; this does not change the persisted goal status. Use `/pause_goal` for a durable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it.
320
+ Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery. A V2 `session.execution.interrupted` event with reason `user`, or a V1 session/assistant `MessageAbortedError`, persists a terminal `cancelled` state for an active goal and invalidates timers and outstanding continuation preparation without charging a prompt failure. A later idle, unrelated prompt, or plugin reload cannot resume that goal; start an explicitly requested new goal with `/goal <objective>` or `/goal replace <objective>`. Cancelling a manual turn preserves paused or limited goals. V2 shutdown, superseded, and other interruptions only suppress local continuation until the host starts another execution; they do not cancel the persisted goal. V1 exposes only `MessageAbortedError`, so it cannot distinguish user cancellation from other host aborts of an active goal. Use `/pause_goal` for a durable, resumable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it.
319
321
 
320
322
  The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. It checks the shared goal state file every second so usage and checkpoints stay current during a long run. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
323
+
324
+
325
+ ### Zed and ACP lifecycle boundaries
326
+
327
+ Cancelling an active goal from an ACP client is durable when OpenCode emits the user-cancellation events described above. The plugin prevents subsequent goal continuations, including callbacks still preparing a prompt when cancellation arrives. OpenCode owns cancellation of in-flight model requests, tools, subprocesses, and already submitted prompts; the plugin cannot guarantee process termination if the host does not abort them or does not publish a cancellation signal. It does not interpret ordinary idle events, provider-error text, or completed tool calls as user cancellation.
328
+
329
+ Goal lifetime and ACP prompt-turn lifetime are separate. The plugin continues an active goal across successful executions, but does not control the ACP adapter's response to `session/prompt`. A host that returns `end_turn` after the initial execution can therefore show an idle client while subsequent goal work runs. In an isolated OpenCode 2.0.21 ACP test, registered `/goal` commands returned `end_turn` while their submitted execution was still running; a normal `session/prompt` remained open and returned `cancelled` after Cancel. Keeping that ACP request open across goal continuations, and exposing structured goal plans as ACP `plan` updates, requires host integration. Request or phase completion alone must not mark a goal complete. Long-running goals remain supported within the configured budgets; this cancellation handling adds no timeout.
package/dist/server.js CHANGED
@@ -179,6 +179,8 @@ var GoalSchema = Schema.Struct({
179
179
  id: Schema.optionalWith(Schema.String, { default: () => "" }),
180
180
  sessionID: Schema.String,
181
181
  objective: Schema.String,
182
+ plan: Schema.optional(Schema.Null),
183
+ planRevision: Schema.optional(Schema.Literal(0)),
182
184
  status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
183
185
  tokenBudget: NullableNumber,
184
186
  tokensUsed: Schema.Number,
@@ -216,6 +218,8 @@ var ArchivedGoalSchema = Schema.Struct({
216
218
  id: Schema.String,
217
219
  sessionID: Schema.String,
218
220
  objective: Schema.String,
221
+ plan: Schema.optional(Schema.Null),
222
+ planRevision: Schema.optional(Schema.Literal(0)),
219
223
  status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
220
224
  tokenBudget: NullableNumber,
221
225
  tokensUsed: Schema.Number,
@@ -234,7 +238,7 @@ var LegacyStateSchema = Schema.Struct({
234
238
  goals: Schema.Record({ key: Schema.String, value: GoalSchema })
235
239
  });
236
240
  var StateSchema = Schema.Struct({
237
- version: Schema.Literal(2),
241
+ version: Schema.Literal(2, 3),
238
242
  goals: Schema.Record({ key: Schema.String, value: GoalSchema }),
239
243
  archives: Schema.optionalWith(Schema.Record({ key: Schema.String, value: Schema.Array(ArchivedGoalSchema) }), {
240
244
  default: () => ({})
@@ -551,6 +555,8 @@ function snapshot(goal) {
551
555
  id: goal.id,
552
556
  sessionID: goal.sessionID,
553
557
  objective: goal.objective,
558
+ ...goal.plan === null ? { plan: null } : {},
559
+ ...goal.planRevision === 0 ? { planRevision: 0 } : {},
554
560
  status: goal.status,
555
561
  tokenBudget: goal.tokenBudget,
556
562
  tokensUsed: goal.tokensUsed,
@@ -678,6 +684,8 @@ function archivedGoal(goal) {
678
684
  id: goal.id,
679
685
  sessionID: goal.sessionID,
680
686
  objective: summarizeText(goal.objective, MAX_ARCHIVED_OBJECTIVE_CHARS),
687
+ ...goal.plan === null ? { plan: null } : {},
688
+ ...goal.planRevision === 0 ? { planRevision: 0 } : {},
681
689
  status: goal.status,
682
690
  tokenBudget: goal.tokenBudget,
683
691
  tokensUsed: goal.tokensUsed,
@@ -888,6 +896,16 @@ async function cancelGoal(sessionID, reason = "cancelled") {
888
896
  return snapshot(goal);
889
897
  });
890
898
  }
899
+ async function cancelActiveGoal(sessionID) {
900
+ return mutate((state) => {
901
+ const goal = state.goals[sessionID];
902
+ if (!goal)
903
+ return null;
904
+ if (goal.status === "active")
905
+ cancelGoalRecord(goal, "cancelled");
906
+ return snapshot(goal);
907
+ });
908
+ }
891
909
  async function clearGoal(sessionID) {
892
910
  return mutate((state) => {
893
911
  const goal = state.goals[sessionID];
@@ -1937,6 +1955,27 @@ var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/
1937
1955
  var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]);
1938
1956
  var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
1939
1957
  var activeContinuations = new Set;
1958
+
1959
+ class ContinuationEpochs {
1960
+ values = new Map;
1961
+ current(sessionID) {
1962
+ return this.values.get(sessionID) ?? 0;
1963
+ }
1964
+ invalidate(sessionID) {
1965
+ this.values.set(sessionID, this.current(sessionID) + 1);
1966
+ }
1967
+ }
1968
+ function isUserAbortEvent(event) {
1969
+ const properties = event.properties;
1970
+ if (event.type === "session.error") {
1971
+ return isRecord(properties?.error) && properties.error.name === "MessageAbortedError";
1972
+ }
1973
+ const message = properties?.info;
1974
+ return event.type === "message.updated" && isRecord(message) && message.role === "assistant" && isRecord(message.error) && message.error.name === "MessageAbortedError";
1975
+ }
1976
+ function continuationStillReserved(goal, current) {
1977
+ return current?.id === goal.id && current.status === goal.status && (goal.status !== "active" || current.pendingAttempt?.id === goal.pendingAttempt?.id);
1978
+ }
1940
1979
  function restrictedAgentSet(options) {
1941
1980
  if (options?.allow_goal_execution_from_plan === true)
1942
1981
  return new Set;
@@ -2963,6 +3002,7 @@ var server = async ({ client }, options) => {
2963
3002
  const toolAttempts = new Map;
2964
3003
  const explicitResumeRequests = new Set;
2965
3004
  const restartAfterContinuation = new Set;
3005
+ const continuationEpochs = new ContinuationEpochs;
2966
3006
  const watchdogRescuedSessions = new Set;
2967
3007
  const planAgents = restrictedAgentSet(options);
2968
3008
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
@@ -2974,6 +3014,7 @@ var server = async ({ client }, options) => {
2974
3014
  maxObjectiveChars: objectiveChars,
2975
3015
  consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID),
2976
3016
  stopAutonomy: (sessionID, mode = "stop") => {
3017
+ continuationEpochs.invalidate(sessionID);
2977
3018
  cancelScheduledContinuation(sessionID);
2978
3019
  if (mode === "stop")
2979
3020
  clearTurnWatchdog(sessionID);
@@ -3027,6 +3068,8 @@ var server = async ({ client }, options) => {
3027
3068
  turnWatchdogs.set(sessionID, watchdog);
3028
3069
  }
3029
3070
  async function runTurnWatchdog(sessionID, watchdog) {
3071
+ const epoch = continuationEpochs.current(sessionID);
3072
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3030
3073
  let claimedContinuation = false;
3031
3074
  let claimedGoalID;
3032
3075
  try {
@@ -3062,17 +3105,23 @@ var server = async ({ client }, options) => {
3062
3105
  claimedContinuation = true;
3063
3106
  claimedGoalID = current.id;
3064
3107
  watchdogRescuedSessions.add(sessionID);
3108
+ if (!isCurrent())
3109
+ return;
3065
3110
  await sendContinuation(client, sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestTurnAgent ?? null);
3066
- await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3111
+ if (!isCurrent())
3112
+ return;
3113
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3067
3114
  armNoProgress: false,
3068
3115
  started: true,
3069
3116
  expectedGoalID: claimedGoalID
3070
3117
  });
3071
- locallyDeliveredPendingSessions.add(sessionID);
3072
- clearTurnWatchdog(sessionID);
3118
+ if (isCurrent() && delivered?.pendingAttempt?.delivered) {
3119
+ locallyDeliveredPendingSessions.add(sessionID);
3120
+ clearTurnWatchdog(sessionID);
3121
+ }
3073
3122
  } catch (error) {
3074
3123
  try {
3075
- if (claimedContinuation && isTransportError(error)) {
3124
+ if (claimedContinuation && isCurrent() && isTransportError(error)) {
3076
3125
  await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
3077
3126
  }
3078
3127
  await client.app?.log?.({
@@ -3142,16 +3191,24 @@ var server = async ({ client }, options) => {
3142
3191
  return;
3143
3192
  if (activeContinuations.has(sessionID))
3144
3193
  return;
3194
+ const epoch = continuationEpochs.current(sessionID);
3195
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3145
3196
  activeContinuations.add(sessionID);
3146
3197
  let attemptReservedAt = Date.now();
3147
3198
  let attemptGoalID;
3148
3199
  let attemptID;
3149
3200
  try {
3150
3201
  const latestAssistant = await fetchLatestAssistant(client, sessionID);
3202
+ if (!isCurrent())
3203
+ return;
3151
3204
  taskTracker.observeAssistantMessage(sessionID, latestAssistant);
3152
3205
  const taskStatus = await taskBlockStatus(sessionID);
3206
+ if (!isCurrent())
3207
+ return;
3153
3208
  if (taskStatus && taskStatus.blocked) {
3154
3209
  const deferralGoal = await getGoalInternal(sessionID);
3210
+ if (!isCurrent())
3211
+ return;
3155
3212
  if (!taskDeferralGoalContinuable(deferralGoal)) {
3156
3213
  taskDeferredSessions.delete(sessionID);
3157
3214
  cancelScheduledContinuation(sessionID);
@@ -3161,7 +3218,7 @@ var server = async ({ client }, options) => {
3161
3218
  scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null || taskStatus.retryAt != null);
3162
3219
  return;
3163
3220
  }
3164
- if (busySessions.has(sessionID))
3221
+ if (!isCurrent() || busySessions.has(sessionID))
3165
3222
  return;
3166
3223
  const observed = await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
3167
3224
  await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
@@ -3171,7 +3228,7 @@ var server = async ({ client }, options) => {
3171
3228
  if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
3172
3229
  return;
3173
3230
  const current = await getGoalInternal(sessionID);
3174
- if (!current)
3231
+ if (!isCurrent() || !current)
3175
3232
  return;
3176
3233
  const latestTurnAgent = agentFromMessage(latestAssistant);
3177
3234
  if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
@@ -3209,7 +3266,7 @@ var server = async ({ client }, options) => {
3209
3266
  return;
3210
3267
  if (!autoContinue)
3211
3268
  return;
3212
- if (nativeRetrySessions.has(sessionID))
3269
+ if (!isCurrent() || nativeRetrySessions.has(sessionID))
3213
3270
  return;
3214
3271
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
3215
3272
  if (!goal)
@@ -3217,7 +3274,8 @@ var server = async ({ client }, options) => {
3217
3274
  attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
3218
3275
  attemptGoalID = goal.id;
3219
3276
  attemptID = goal.pendingAttempt?.id;
3220
- if (nativeRetrySessions.has(sessionID)) {
3277
+ const beforeDelivery = await getGoalInternal(sessionID);
3278
+ if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
3221
3279
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3222
3280
  return;
3223
3281
  }
@@ -3226,19 +3284,20 @@ var server = async ({ client }, options) => {
3226
3284
  return;
3227
3285
  }
3228
3286
  await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
3229
- if (disposed) {
3287
+ if (!isCurrent()) {
3230
3288
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3231
3289
  return;
3232
3290
  }
3233
3291
  const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3234
3292
  expectedGoalID: attemptGoalID
3235
3293
  });
3236
- locallyDeliveredPendingSessions.add(sessionID);
3294
+ if (isCurrent() && delivered?.pendingAttempt?.delivered)
3295
+ locallyDeliveredPendingSessions.add(sessionID);
3237
3296
  if (!delivered?.pendingAttempt?.delivered) {
3238
3297
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3239
3298
  }
3240
3299
  } catch (error) {
3241
- if (disposed) {
3300
+ if (!isCurrent()) {
3242
3301
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3243
3302
  return;
3244
3303
  }
@@ -3503,6 +3562,17 @@ var server = async ({ client }, options) => {
3503
3562
  async event({ event }) {
3504
3563
  const sessionID = sessionIDFromEvent(event);
3505
3564
  const eventType = event.type;
3565
+ if (sessionID && isUserAbortEvent(event)) {
3566
+ explicitResumeRequests.delete(sessionID);
3567
+ goalServices.stopAutonomy?.(sessionID);
3568
+ busySessions.delete(sessionID);
3569
+ nativeRetrySessions.delete(sessionID);
3570
+ watchdogRescuedSessions.delete(sessionID);
3571
+ clearToolAttemptsForSession(toolAttempts, sessionID);
3572
+ taskTracker.observeSessionStatus(sessionID, "idle");
3573
+ await cancelActiveGoal(sessionID);
3574
+ return;
3575
+ }
3506
3576
  if (eventType === "session.created") {
3507
3577
  taskTracker.observeSessionCreated(event);
3508
3578
  }
@@ -3571,6 +3641,7 @@ var server = async ({ client }, options) => {
3571
3641
  }
3572
3642
  }
3573
3643
  if (sessionID && eventType === "session.deleted") {
3644
+ continuationEpochs.invalidate(sessionID);
3574
3645
  explicitResumeRequests.delete(sessionID);
3575
3646
  busySessions.delete(sessionID);
3576
3647
  clearTurnWatchdog(sessionID);
@@ -3635,6 +3706,7 @@ async function setupV2(context) {
3635
3706
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
3636
3707
  const activeContinuationsV2 = new Set;
3637
3708
  const restartAfterContinuation = new Set;
3709
+ const continuationEpochs = new ContinuationEpochs;
3638
3710
  const stoppedExecutions = new Set;
3639
3711
  const latestStepBySession = new Map;
3640
3712
  const stepTextBuffers = new Map;
@@ -3654,6 +3726,7 @@ async function setupV2(context) {
3654
3726
  }
3655
3727
  },
3656
3728
  stopAutonomy: (sessionID, mode = "stop") => {
3729
+ continuationEpochs.invalidate(sessionID);
3657
3730
  cancelScheduledContinuation(sessionID);
3658
3731
  if (mode === "stop")
3659
3732
  clearTurnWatchdog(sessionID);
@@ -3712,6 +3785,8 @@ async function setupV2(context) {
3712
3785
  turnWatchdogs.set(sessionID, watchdog);
3713
3786
  }
3714
3787
  async function runTurnWatchdog(sessionID, watchdog) {
3788
+ const epoch = continuationEpochs.current(sessionID);
3789
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3715
3790
  let claimedContinuation = false;
3716
3791
  let claimedGoalID;
3717
3792
  try {
@@ -3743,17 +3818,23 @@ async function setupV2(context) {
3743
3818
  claimedContinuation = true;
3744
3819
  claimedGoalID = current.id;
3745
3820
  watchdogRescuedSessions.add(sessionID);
3821
+ if (!isCurrent())
3822
+ return;
3746
3823
  await sendContinuation(sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestStep?.agent ?? null);
3747
- await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3824
+ if (!isCurrent())
3825
+ return;
3826
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3748
3827
  armNoProgress: false,
3749
3828
  started: true,
3750
3829
  expectedGoalID: claimedGoalID
3751
3830
  });
3752
- locallyDeliveredPendingSessions.add(sessionID);
3753
- clearTurnWatchdog(sessionID);
3831
+ if (isCurrent() && delivered?.pendingAttempt?.delivered) {
3832
+ locallyDeliveredPendingSessions.add(sessionID);
3833
+ clearTurnWatchdog(sessionID);
3834
+ }
3754
3835
  } catch (error) {
3755
3836
  try {
3756
- if (claimedContinuation && isTransportError(error)) {
3837
+ if (claimedContinuation && isCurrent() && isTransportError(error)) {
3757
3838
  await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
3758
3839
  }
3759
3840
  v2ErrorLog("Turn watchdog retry failed", error);
@@ -3818,8 +3899,10 @@ async function setupV2(context) {
3818
3899
  return;
3819
3900
  if (activeContinuationsV2.has(sessionID))
3820
3901
  return;
3902
+ const epoch = continuationEpochs.current(sessionID);
3903
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3821
3904
  await taskRecoveryComplete;
3822
- if (disposed || stoppedExecutions.has(sessionID) || busySessions.has(sessionID))
3905
+ if (!isCurrent() || stoppedExecutions.has(sessionID) || busySessions.has(sessionID))
3823
3906
  return;
3824
3907
  activeContinuationsV2.add(sessionID);
3825
3908
  let attemptReservedAt = Date.now();
@@ -3833,6 +3916,8 @@ async function setupV2(context) {
3833
3916
  const taskStatus = taskBlockStatus(sessionID);
3834
3917
  if (taskStatus && taskStatus.blocked) {
3835
3918
  const deferralGoal = await getGoalInternal(sessionID);
3919
+ if (!isCurrent())
3920
+ return;
3836
3921
  if (!taskDeferralGoalContinuable(deferralGoal)) {
3837
3922
  taskDeferredSessions.delete(sessionID);
3838
3923
  cancelScheduledContinuation(sessionID);
@@ -3864,7 +3949,7 @@ async function setupV2(context) {
3864
3949
  if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
3865
3950
  return;
3866
3951
  const current = await getGoalInternal(sessionID);
3867
- if (!current)
3952
+ if (!isCurrent() || !current)
3868
3953
  return;
3869
3954
  const latestTurnAgent = latestStep?.agent;
3870
3955
  if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
@@ -3902,7 +3987,7 @@ async function setupV2(context) {
3902
3987
  return;
3903
3988
  if (!autoContinue)
3904
3989
  return;
3905
- if (nativeRetrySessions.has(sessionID))
3990
+ if (!isCurrent() || nativeRetrySessions.has(sessionID))
3906
3991
  return;
3907
3992
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
3908
3993
  if (!goal) {
@@ -3915,7 +4000,8 @@ async function setupV2(context) {
3915
4000
  attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
3916
4001
  attemptGoalID = goal.id;
3917
4002
  attemptID = goal.pendingAttempt?.id;
3918
- if (nativeRetrySessions.has(sessionID)) {
4003
+ const beforeDelivery = await getGoalInternal(sessionID);
4004
+ if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
3919
4005
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3920
4006
  return;
3921
4007
  }
@@ -3924,19 +4010,20 @@ async function setupV2(context) {
3924
4010
  return;
3925
4011
  }
3926
4012
  await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
3927
- if (disposed) {
4013
+ if (!isCurrent()) {
3928
4014
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3929
4015
  return;
3930
4016
  }
3931
4017
  const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3932
4018
  expectedGoalID: attemptGoalID
3933
4019
  });
3934
- locallyDeliveredPendingSessions.add(sessionID);
4020
+ if (isCurrent() && delivered?.pendingAttempt?.delivered)
4021
+ locallyDeliveredPendingSessions.add(sessionID);
3935
4022
  if (!delivered?.pendingAttempt?.delivered) {
3936
4023
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3937
4024
  }
3938
4025
  } catch (error) {
3939
- if (disposed) {
4026
+ if (!isCurrent()) {
3940
4027
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3941
4028
  return;
3942
4029
  }
@@ -4101,9 +4188,11 @@ async function setupV2(context) {
4101
4188
  nativeRetrySessions.delete(sessionID);
4102
4189
  clearTurnWatchdog(sessionID);
4103
4190
  watchdogRescuedSessions.delete(sessionID);
4104
- cancelScheduledContinuation(sessionID);
4105
- taskDeferredSessions.delete(sessionID);
4191
+ goalServices.stopAutonomy?.(sessionID);
4192
+ clearToolAttemptsForSession(toolAttempts, sessionID);
4106
4193
  taskTracker.observeSessionStatus(sessionID, "idle");
4194
+ if (data.reason === "user")
4195
+ await cancelActiveGoal(sessionID);
4107
4196
  return;
4108
4197
  }
4109
4198
  case "session.execution.failed": {
@@ -4144,6 +4233,7 @@ async function setupV2(context) {
4144
4233
  case "session.deleted": {
4145
4234
  if (!sessionID)
4146
4235
  return;
4236
+ continuationEpochs.invalidate(sessionID);
4147
4237
  explicitResumeRequests.delete(sessionID);
4148
4238
  stoppedExecutions.delete(sessionID);
4149
4239
  sessionOwnership.delete(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
4
4
  "description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
5
5
  "keywords": [
6
6
  "opencode",
package/src/tui.ts CHANGED
@@ -419,10 +419,12 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
419
419
  export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
420
420
  try {
421
421
  const state: unknown = JSON.parse(await readFile(statePath(), "utf8"))
422
- if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined
422
+ if (!isRecord(state) || (state.version !== 1 && state.version !== 2 && state.version !== 3) || !isRecord(state.goals)) return undefined
423
423
  const stored = state.goals[sessionID]
424
424
  if (stored === undefined) return null
425
425
  if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
426
+ if (stored.plan !== undefined && stored.plan !== null) return undefined
427
+ if (stored.planRevision !== undefined && stored.planRevision !== 0) return undefined
426
428
  const sampledAt = currentEpochSeconds()
427
429
  const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
428
430
  const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"