@prevalentware/opencode-goal-plugin 0.1.53 → 0.1.55

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
@@ -315,6 +315,13 @@ OpenCode plugin modules are target-specific. This package exports separate modul
315
315
  }
316
316
  ```
317
317
 
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.
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. 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
319
 
320
- 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. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
320
+ 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.
321
+
322
+
323
+ ### Zed and ACP lifecycle boundaries
324
+
325
+ 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.
326
+
327
+ 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
@@ -5,8 +5,7 @@ import { z } from "zod";
5
5
  // src/state.ts
6
6
  import { randomUUID as randomUUID2 } from "crypto";
7
7
  import { mkdir, readFile } from "fs/promises";
8
- import { homedir } from "os";
9
- import { dirname as dirname2, join } from "path";
8
+ import { dirname as dirname2 } from "path";
10
9
  import { Data, Effect, Schema } from "effect";
11
10
 
12
11
  // src/atomic-write.ts
@@ -117,6 +116,16 @@ async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
117
116
  }
118
117
  }
119
118
 
119
+ // src/state-path.ts
120
+ import { homedir } from "os";
121
+ import { join } from "path";
122
+ function statePath() {
123
+ if (process.env.OPENCODE_GOAL_STATE_PATH)
124
+ return process.env.OPENCODE_GOAL_STATE_PATH;
125
+ const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
126
+ return join(dataHome, "opencode-goal-plugin", "goals.json");
127
+ }
128
+
120
129
  // src/state.ts
121
130
  class StateReadError extends Data.TaggedError("StateReadError") {
122
131
  }
@@ -232,13 +241,6 @@ var StateSchema = Schema.Struct({
232
241
  })
233
242
  });
234
243
  var PersistedStateSchema = Schema.Union(LegacyStateSchema, StateSchema);
235
- function defaultStateFile() {
236
- const dataHome = process.env.XDG_DATA_HOME || (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"));
237
- return join(dataHome, "opencode-goal-plugin", "goals.json");
238
- }
239
- function statePath() {
240
- return process.env.OPENCODE_GOAL_STATE_PATH || defaultStateFile();
241
- }
242
244
  function nowSeconds() {
243
245
  return Math.floor(Date.now() / 1000);
244
246
  }
@@ -886,6 +888,16 @@ async function cancelGoal(sessionID, reason = "cancelled") {
886
888
  return snapshot(goal);
887
889
  });
888
890
  }
891
+ async function cancelActiveGoal(sessionID) {
892
+ return mutate((state) => {
893
+ const goal = state.goals[sessionID];
894
+ if (!goal)
895
+ return null;
896
+ if (goal.status === "active")
897
+ cancelGoalRecord(goal, "cancelled");
898
+ return snapshot(goal);
899
+ });
900
+ }
889
901
  async function clearGoal(sessionID) {
890
902
  return mutate((state) => {
891
903
  const goal = state.goals[sessionID];
@@ -1935,6 +1947,27 @@ var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/
1935
1947
  var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]);
1936
1948
  var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
1937
1949
  var activeContinuations = new Set;
1950
+
1951
+ class ContinuationEpochs {
1952
+ values = new Map;
1953
+ current(sessionID) {
1954
+ return this.values.get(sessionID) ?? 0;
1955
+ }
1956
+ invalidate(sessionID) {
1957
+ this.values.set(sessionID, this.current(sessionID) + 1);
1958
+ }
1959
+ }
1960
+ function isUserAbortEvent(event) {
1961
+ const properties = event.properties;
1962
+ if (event.type === "session.error") {
1963
+ return isRecord(properties?.error) && properties.error.name === "MessageAbortedError";
1964
+ }
1965
+ const message = properties?.info;
1966
+ return event.type === "message.updated" && isRecord(message) && message.role === "assistant" && isRecord(message.error) && message.error.name === "MessageAbortedError";
1967
+ }
1968
+ function continuationStillReserved(goal, current) {
1969
+ return current?.id === goal.id && current.status === goal.status && (goal.status !== "active" || current.pendingAttempt?.id === goal.pendingAttempt?.id);
1970
+ }
1938
1971
  function restrictedAgentSet(options) {
1939
1972
  if (options?.allow_goal_execution_from_plan === true)
1940
1973
  return new Set;
@@ -2961,6 +2994,7 @@ var server = async ({ client }, options) => {
2961
2994
  const toolAttempts = new Map;
2962
2995
  const explicitResumeRequests = new Set;
2963
2996
  const restartAfterContinuation = new Set;
2997
+ const continuationEpochs = new ContinuationEpochs;
2964
2998
  const watchdogRescuedSessions = new Set;
2965
2999
  const planAgents = restrictedAgentSet(options);
2966
3000
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
@@ -2972,6 +3006,7 @@ var server = async ({ client }, options) => {
2972
3006
  maxObjectiveChars: objectiveChars,
2973
3007
  consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID),
2974
3008
  stopAutonomy: (sessionID, mode = "stop") => {
3009
+ continuationEpochs.invalidate(sessionID);
2975
3010
  cancelScheduledContinuation(sessionID);
2976
3011
  if (mode === "stop")
2977
3012
  clearTurnWatchdog(sessionID);
@@ -3025,6 +3060,8 @@ var server = async ({ client }, options) => {
3025
3060
  turnWatchdogs.set(sessionID, watchdog);
3026
3061
  }
3027
3062
  async function runTurnWatchdog(sessionID, watchdog) {
3063
+ const epoch = continuationEpochs.current(sessionID);
3064
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3028
3065
  let claimedContinuation = false;
3029
3066
  let claimedGoalID;
3030
3067
  try {
@@ -3060,17 +3097,23 @@ var server = async ({ client }, options) => {
3060
3097
  claimedContinuation = true;
3061
3098
  claimedGoalID = current.id;
3062
3099
  watchdogRescuedSessions.add(sessionID);
3100
+ if (!isCurrent())
3101
+ return;
3063
3102
  await sendContinuation(client, sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestTurnAgent ?? null);
3064
- await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3103
+ if (!isCurrent())
3104
+ return;
3105
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3065
3106
  armNoProgress: false,
3066
3107
  started: true,
3067
3108
  expectedGoalID: claimedGoalID
3068
3109
  });
3069
- locallyDeliveredPendingSessions.add(sessionID);
3070
- clearTurnWatchdog(sessionID);
3110
+ if (isCurrent() && delivered?.pendingAttempt?.delivered) {
3111
+ locallyDeliveredPendingSessions.add(sessionID);
3112
+ clearTurnWatchdog(sessionID);
3113
+ }
3071
3114
  } catch (error) {
3072
3115
  try {
3073
- if (claimedContinuation && isTransportError(error)) {
3116
+ if (claimedContinuation && isCurrent() && isTransportError(error)) {
3074
3117
  await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
3075
3118
  }
3076
3119
  await client.app?.log?.({
@@ -3140,16 +3183,24 @@ var server = async ({ client }, options) => {
3140
3183
  return;
3141
3184
  if (activeContinuations.has(sessionID))
3142
3185
  return;
3186
+ const epoch = continuationEpochs.current(sessionID);
3187
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3143
3188
  activeContinuations.add(sessionID);
3144
3189
  let attemptReservedAt = Date.now();
3145
3190
  let attemptGoalID;
3146
3191
  let attemptID;
3147
3192
  try {
3148
3193
  const latestAssistant = await fetchLatestAssistant(client, sessionID);
3194
+ if (!isCurrent())
3195
+ return;
3149
3196
  taskTracker.observeAssistantMessage(sessionID, latestAssistant);
3150
3197
  const taskStatus = await taskBlockStatus(sessionID);
3198
+ if (!isCurrent())
3199
+ return;
3151
3200
  if (taskStatus && taskStatus.blocked) {
3152
3201
  const deferralGoal = await getGoalInternal(sessionID);
3202
+ if (!isCurrent())
3203
+ return;
3153
3204
  if (!taskDeferralGoalContinuable(deferralGoal)) {
3154
3205
  taskDeferredSessions.delete(sessionID);
3155
3206
  cancelScheduledContinuation(sessionID);
@@ -3159,7 +3210,7 @@ var server = async ({ client }, options) => {
3159
3210
  scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null || taskStatus.retryAt != null);
3160
3211
  return;
3161
3212
  }
3162
- if (busySessions.has(sessionID))
3213
+ if (!isCurrent() || busySessions.has(sessionID))
3163
3214
  return;
3164
3215
  const observed = await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
3165
3216
  await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
@@ -3169,7 +3220,7 @@ var server = async ({ client }, options) => {
3169
3220
  if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
3170
3221
  return;
3171
3222
  const current = await getGoalInternal(sessionID);
3172
- if (!current)
3223
+ if (!isCurrent() || !current)
3173
3224
  return;
3174
3225
  const latestTurnAgent = agentFromMessage(latestAssistant);
3175
3226
  if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
@@ -3207,7 +3258,7 @@ var server = async ({ client }, options) => {
3207
3258
  return;
3208
3259
  if (!autoContinue)
3209
3260
  return;
3210
- if (nativeRetrySessions.has(sessionID))
3261
+ if (!isCurrent() || nativeRetrySessions.has(sessionID))
3211
3262
  return;
3212
3263
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
3213
3264
  if (!goal)
@@ -3215,7 +3266,8 @@ var server = async ({ client }, options) => {
3215
3266
  attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
3216
3267
  attemptGoalID = goal.id;
3217
3268
  attemptID = goal.pendingAttempt?.id;
3218
- if (nativeRetrySessions.has(sessionID)) {
3269
+ const beforeDelivery = await getGoalInternal(sessionID);
3270
+ if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
3219
3271
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3220
3272
  return;
3221
3273
  }
@@ -3224,19 +3276,20 @@ var server = async ({ client }, options) => {
3224
3276
  return;
3225
3277
  }
3226
3278
  await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
3227
- if (disposed) {
3279
+ if (!isCurrent()) {
3228
3280
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3229
3281
  return;
3230
3282
  }
3231
3283
  const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3232
3284
  expectedGoalID: attemptGoalID
3233
3285
  });
3234
- locallyDeliveredPendingSessions.add(sessionID);
3286
+ if (isCurrent() && delivered?.pendingAttempt?.delivered)
3287
+ locallyDeliveredPendingSessions.add(sessionID);
3235
3288
  if (!delivered?.pendingAttempt?.delivered) {
3236
3289
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3237
3290
  }
3238
3291
  } catch (error) {
3239
- if (disposed) {
3292
+ if (!isCurrent()) {
3240
3293
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3241
3294
  return;
3242
3295
  }
@@ -3501,6 +3554,17 @@ var server = async ({ client }, options) => {
3501
3554
  async event({ event }) {
3502
3555
  const sessionID = sessionIDFromEvent(event);
3503
3556
  const eventType = event.type;
3557
+ if (sessionID && isUserAbortEvent(event)) {
3558
+ explicitResumeRequests.delete(sessionID);
3559
+ goalServices.stopAutonomy?.(sessionID);
3560
+ busySessions.delete(sessionID);
3561
+ nativeRetrySessions.delete(sessionID);
3562
+ watchdogRescuedSessions.delete(sessionID);
3563
+ clearToolAttemptsForSession(toolAttempts, sessionID);
3564
+ taskTracker.observeSessionStatus(sessionID, "idle");
3565
+ await cancelActiveGoal(sessionID);
3566
+ return;
3567
+ }
3504
3568
  if (eventType === "session.created") {
3505
3569
  taskTracker.observeSessionCreated(event);
3506
3570
  }
@@ -3569,6 +3633,7 @@ var server = async ({ client }, options) => {
3569
3633
  }
3570
3634
  }
3571
3635
  if (sessionID && eventType === "session.deleted") {
3636
+ continuationEpochs.invalidate(sessionID);
3572
3637
  explicitResumeRequests.delete(sessionID);
3573
3638
  busySessions.delete(sessionID);
3574
3639
  clearTurnWatchdog(sessionID);
@@ -3633,6 +3698,7 @@ async function setupV2(context) {
3633
3698
  const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
3634
3699
  const activeContinuationsV2 = new Set;
3635
3700
  const restartAfterContinuation = new Set;
3701
+ const continuationEpochs = new ContinuationEpochs;
3636
3702
  const stoppedExecutions = new Set;
3637
3703
  const latestStepBySession = new Map;
3638
3704
  const stepTextBuffers = new Map;
@@ -3652,6 +3718,7 @@ async function setupV2(context) {
3652
3718
  }
3653
3719
  },
3654
3720
  stopAutonomy: (sessionID, mode = "stop") => {
3721
+ continuationEpochs.invalidate(sessionID);
3655
3722
  cancelScheduledContinuation(sessionID);
3656
3723
  if (mode === "stop")
3657
3724
  clearTurnWatchdog(sessionID);
@@ -3710,6 +3777,8 @@ async function setupV2(context) {
3710
3777
  turnWatchdogs.set(sessionID, watchdog);
3711
3778
  }
3712
3779
  async function runTurnWatchdog(sessionID, watchdog) {
3780
+ const epoch = continuationEpochs.current(sessionID);
3781
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3713
3782
  let claimedContinuation = false;
3714
3783
  let claimedGoalID;
3715
3784
  try {
@@ -3741,17 +3810,23 @@ async function setupV2(context) {
3741
3810
  claimedContinuation = true;
3742
3811
  claimedGoalID = current.id;
3743
3812
  watchdogRescuedSessions.add(sessionID);
3813
+ if (!isCurrent())
3814
+ return;
3744
3815
  await sendContinuation(sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestStep?.agent ?? null);
3745
- await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3816
+ if (!isCurrent())
3817
+ return;
3818
+ const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3746
3819
  armNoProgress: false,
3747
3820
  started: true,
3748
3821
  expectedGoalID: claimedGoalID
3749
3822
  });
3750
- locallyDeliveredPendingSessions.add(sessionID);
3751
- clearTurnWatchdog(sessionID);
3823
+ if (isCurrent() && delivered?.pendingAttempt?.delivered) {
3824
+ locallyDeliveredPendingSessions.add(sessionID);
3825
+ clearTurnWatchdog(sessionID);
3826
+ }
3752
3827
  } catch (error) {
3753
3828
  try {
3754
- if (claimedContinuation && isTransportError(error)) {
3829
+ if (claimedContinuation && isCurrent() && isTransportError(error)) {
3755
3830
  await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
3756
3831
  }
3757
3832
  v2ErrorLog("Turn watchdog retry failed", error);
@@ -3816,8 +3891,10 @@ async function setupV2(context) {
3816
3891
  return;
3817
3892
  if (activeContinuationsV2.has(sessionID))
3818
3893
  return;
3894
+ const epoch = continuationEpochs.current(sessionID);
3895
+ const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
3819
3896
  await taskRecoveryComplete;
3820
- if (disposed || stoppedExecutions.has(sessionID) || busySessions.has(sessionID))
3897
+ if (!isCurrent() || stoppedExecutions.has(sessionID) || busySessions.has(sessionID))
3821
3898
  return;
3822
3899
  activeContinuationsV2.add(sessionID);
3823
3900
  let attemptReservedAt = Date.now();
@@ -3831,6 +3908,8 @@ async function setupV2(context) {
3831
3908
  const taskStatus = taskBlockStatus(sessionID);
3832
3909
  if (taskStatus && taskStatus.blocked) {
3833
3910
  const deferralGoal = await getGoalInternal(sessionID);
3911
+ if (!isCurrent())
3912
+ return;
3834
3913
  if (!taskDeferralGoalContinuable(deferralGoal)) {
3835
3914
  taskDeferredSessions.delete(sessionID);
3836
3915
  cancelScheduledContinuation(sessionID);
@@ -3862,7 +3941,7 @@ async function setupV2(context) {
3862
3941
  if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
3863
3942
  return;
3864
3943
  const current = await getGoalInternal(sessionID);
3865
- if (!current)
3944
+ if (!isCurrent() || !current)
3866
3945
  return;
3867
3946
  const latestTurnAgent = latestStep?.agent;
3868
3947
  if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
@@ -3900,7 +3979,7 @@ async function setupV2(context) {
3900
3979
  return;
3901
3980
  if (!autoContinue)
3902
3981
  return;
3903
- if (nativeRetrySessions.has(sessionID))
3982
+ if (!isCurrent() || nativeRetrySessions.has(sessionID))
3904
3983
  return;
3905
3984
  const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
3906
3985
  if (!goal) {
@@ -3913,7 +3992,8 @@ async function setupV2(context) {
3913
3992
  attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
3914
3993
  attemptGoalID = goal.id;
3915
3994
  attemptID = goal.pendingAttempt?.id;
3916
- if (nativeRetrySessions.has(sessionID)) {
3995
+ const beforeDelivery = await getGoalInternal(sessionID);
3996
+ if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
3917
3997
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3918
3998
  return;
3919
3999
  }
@@ -3922,19 +4002,20 @@ async function setupV2(context) {
3922
4002
  return;
3923
4003
  }
3924
4004
  await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
3925
- if (disposed) {
4005
+ if (!isCurrent()) {
3926
4006
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3927
4007
  return;
3928
4008
  }
3929
4009
  const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
3930
4010
  expectedGoalID: attemptGoalID
3931
4011
  });
3932
- locallyDeliveredPendingSessions.add(sessionID);
4012
+ if (isCurrent() && delivered?.pendingAttempt?.delivered)
4013
+ locallyDeliveredPendingSessions.add(sessionID);
3933
4014
  if (!delivered?.pendingAttempt?.delivered) {
3934
4015
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3935
4016
  }
3936
4017
  } catch (error) {
3937
- if (disposed) {
4018
+ if (!isCurrent()) {
3938
4019
  await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
3939
4020
  return;
3940
4021
  }
@@ -4099,9 +4180,11 @@ async function setupV2(context) {
4099
4180
  nativeRetrySessions.delete(sessionID);
4100
4181
  clearTurnWatchdog(sessionID);
4101
4182
  watchdogRescuedSessions.delete(sessionID);
4102
- cancelScheduledContinuation(sessionID);
4103
- taskDeferredSessions.delete(sessionID);
4183
+ goalServices.stopAutonomy?.(sessionID);
4184
+ clearToolAttemptsForSession(toolAttempts, sessionID);
4104
4185
  taskTracker.observeSessionStatus(sessionID, "idle");
4186
+ if (data.reason === "user")
4187
+ await cancelActiveGoal(sessionID);
4105
4188
  return;
4106
4189
  }
4107
4190
  case "session.execution.failed": {
@@ -4142,6 +4225,7 @@ async function setupV2(context) {
4142
4225
  case "session.deleted": {
4143
4226
  if (!sessionID)
4144
4227
  return;
4228
+ continuationEpochs.invalidate(sessionID);
4145
4229
  explicitResumeRequests.delete(sessionID);
4146
4230
  stoppedExecutions.delete(sessionID);
4147
4231
  sessionOwnership.delete(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
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",
@@ -40,6 +40,7 @@
40
40
  "dist",
41
41
  "src/tui.ts",
42
42
  "src/i18n.ts",
43
+ "src/state-path.ts",
43
44
  "LICENSE",
44
45
  "README.md"
45
46
  ],
@@ -0,0 +1,10 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export function statePath() {
5
+ if (process.env.OPENCODE_GOAL_STATE_PATH) return process.env.OPENCODE_GOAL_STATE_PATH
6
+ const dataHome =
7
+ process.env.XDG_DATA_HOME ||
8
+ (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : join(homedir(), ".local", "share"))
9
+ return join(dataHome, "opencode-goal-plugin", "goals.json")
10
+ }
package/src/tui.ts CHANGED
@@ -3,8 +3,10 @@ import type { Plugin as TuiPluginV2 } from "@opencode/plugin/tui"
3
3
  import type { SessionMessageInfo } from "@opencode/client"
4
4
  import { createElement, insert, setProp } from "@opentui/solid"
5
5
  import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
6
+ import { readFile, stat } from "node:fs/promises"
6
7
  import type { GoalMessages } from "./i18n"
7
8
  import { messagesFor, presentGoalLastStatus, presentGoalStatus, presentGoalStopReason, resolveLocale } from "./i18n"
9
+ import { statePath } from "./state-path"
8
10
 
9
11
  type GoalCheckpoint = {
10
12
  summary: string
@@ -413,6 +415,66 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
413
415
  return true
414
416
  }
415
417
 
418
+ /** Read only the current session from the server's atomic state file. */
419
+ export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
420
+ try {
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
423
+ const stored = state.goals[sessionID]
424
+ if (stored === undefined) return null
425
+ if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
426
+ const sampledAt = currentEpochSeconds()
427
+ const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
428
+ const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"
429
+ ? Math.max(0, sampledAt - stored.lastAccountedAt)
430
+ : 0
431
+ const tokenBudget = stored.tokenBudget
432
+ const tokensUsed = stored.tokensUsed
433
+ const snapshot = {
434
+ ...stored,
435
+ timeUsedSeconds: timeUsedSeconds + activeSeconds,
436
+ sampledAt,
437
+ remainingTokens: typeof tokenBudget === "number" && typeof tokensUsed === "number"
438
+ ? Math.max(0, tokenBudget - tokensUsed)
439
+ : null,
440
+ }
441
+ return isGoalSnapshot(snapshot) ? snapshot : undefined
442
+ } catch {
443
+ // Keep the last valid snapshot when the file is unavailable or incomplete.
444
+ return undefined
445
+ }
446
+ }
447
+
448
+ function usePersistedGoal(sessionID: string) {
449
+ const [persisted, setPersisted] = createSignal<GoalSnapshot | null | undefined>(undefined)
450
+ let disposed = false
451
+ let reading = false
452
+ let lastFile: { ino: number; mtimeMs: number; size: number } | undefined
453
+ const refresh = async () => {
454
+ if (reading) return
455
+ reading = true
456
+ try {
457
+ const file = await stat(statePath())
458
+ if (lastFile && lastFile.ino === file.ino && lastFile.mtimeMs === file.mtimeMs && lastFile.size === file.size) return
459
+ const next = await readPersistedGoal(sessionID)
460
+ if (next === undefined) return
461
+ lastFile = { ino: file.ino, mtimeMs: file.mtimeMs, size: file.size }
462
+ if (!disposed && (next !== null || persisted() !== undefined)) setPersisted(() => next)
463
+ } catch {
464
+ // Missing or unreadable state leaves the last valid view in place.
465
+ } finally {
466
+ reading = false
467
+ }
468
+ }
469
+ void refresh()
470
+ const timer = setInterval(() => void refresh(), 1000)
471
+ onCleanup(() => {
472
+ disposed = true
473
+ clearInterval(timer)
474
+ })
475
+ return persisted
476
+ }
477
+
416
478
  function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefined {
417
479
  if (part.type !== "tool") return undefined
418
480
  if (!GOAL_TOOL_NAMES.includes(part.tool ?? "")) return undefined
@@ -474,39 +536,50 @@ export function formatGoal(goal: GoalSnapshot | null, messages: GoalMessages, lo
474
536
  function GoalSidebar(api: TuiPluginApi, messages: GoalMessages, locale: ReturnType<typeof resolveLocale>, sessionID: string) {
475
537
  const theme = api.theme.current
476
538
  const state = goalStateFromSession(api, sessionID)
477
- const goal = state.goal
478
- if (!goal) return null
479
- if (goal.status === "complete" || goal.status === "unmet") {
480
- const elapsed = liveTimeUsedSeconds(goal)
481
- const label = goal.status === "complete" ? messages.tui.achieved : messages.tui.unmet
482
- return text({ fg: goal.status === "complete" ? theme.primary : theme.textMuted }, [
483
- `${label} (${formatDurationBadge(elapsed)})`,
484
- ])
485
- }
539
+ if (!state.goal) return null
540
+ const persisted = usePersistedGoal(sessionID)
541
+ const goal = createMemo(() => {
542
+ const live = persisted()
543
+ if (live === undefined) return state.goal
544
+ if (live && state.goal && (state.goal.createdAt > live.createdAt || state.goal.updatedAt > live.updatedAt)) return state.goal
545
+ return live
546
+ })
486
547
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
487
- if (goal.status === "active") {
548
+ const active = createMemo(() => goal()?.status === "active")
549
+ createEffect(() => {
550
+ if (!active()) return
488
551
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
489
552
  onCleanup(() => clearInterval(timer))
490
- }
491
- return box({}, [
492
- text({ fg: theme.text }, [messages.tui.title]),
493
- text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(goal.status, locale)}`]),
494
- text({ fg: theme.textMuted }, [() => `${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(goal, nowSeconds()))}`]),
495
- text({ fg: theme.textMuted }, [
496
- `${messages.tui.tokens}: ${goal.tokensUsed}${goal.tokenBudget == null ? "" : `/${goal.tokenBudget}`}`,
497
- ]),
498
- text({ fg: theme.textMuted }, [
499
- `${messages.tui.autoContinues}: ${goal.autoTurns}${goal.maxAutoTurns == null ? "" : `/${goal.maxAutoTurns}`}`,
500
- ]),
501
- ...(goal.lastCheckpoint
502
- ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${goal.lastCheckpoint.summary}`])]
503
- : []),
504
- ...(goal.stopReason
505
- ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(goal.stopReason, locale)}`])]
506
- : []),
507
- ...(goal.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(goal.lastStatus, locale)])] : []),
508
- text({ fg: theme.textMuted }, [goal.objective]),
509
- ])
553
+ })
554
+ return box({}, [() => {
555
+ const snapshot = goal()
556
+ if (!snapshot) return null
557
+ if (snapshot.status === "complete" || snapshot.status === "unmet") {
558
+ const elapsed = liveTimeUsedSeconds(snapshot)
559
+ return text({ fg: snapshot.status === "complete" ? theme.primary : theme.textMuted }, [
560
+ `${snapshot.status === "complete" ? messages.tui.achieved : messages.tui.unmet} (${formatDurationBadge(elapsed)})`,
561
+ ])
562
+ }
563
+ return box({}, [
564
+ text({ fg: theme.text }, [messages.tui.title]),
565
+ text({ fg: theme.textMuted }, [`${messages.tui.status}: ${presentGoalStatus(snapshot.status, locale)}`]),
566
+ text({ fg: theme.textMuted }, [`${messages.tui.time}: ${formatDuration(liveTimeUsedSeconds(snapshot, nowSeconds()))}`]),
567
+ text({ fg: theme.textMuted }, [
568
+ `${messages.tui.tokens}: ${snapshot.tokensUsed}${snapshot.tokenBudget == null ? "" : `/${snapshot.tokenBudget}`}`,
569
+ ]),
570
+ text({ fg: theme.textMuted }, [
571
+ `${messages.tui.autoContinues}: ${snapshot.autoTurns}${snapshot.maxAutoTurns == null ? "" : `/${snapshot.maxAutoTurns}`}`,
572
+ ]),
573
+ ...(snapshot.lastCheckpoint
574
+ ? [text({ fg: theme.textMuted }, [`${messages.tui.checkpoint}: ${snapshot.lastCheckpoint.summary}`])]
575
+ : []),
576
+ ...(snapshot.stopReason
577
+ ? [text({ fg: theme.textMuted }, [`${messages.tui.stop}: ${presentGoalStopReason(snapshot.stopReason, locale)}`])]
578
+ : []),
579
+ ...(snapshot.lastStatus ? [text({ fg: theme.textMuted }, [presentGoalLastStatus(snapshot.lastStatus, locale)])] : []),
580
+ text({ fg: theme.textMuted }, [snapshot.objective]),
581
+ ])
582
+ }])
510
583
  }
511
584
 
512
585
  function registerGoalCommand(api: TuiPluginApi, command: TuiCommand) {
@@ -657,8 +730,16 @@ function GoalSidebarV2(
657
730
  const [cache, setCache] = api.storage.memory<{ goal: GoalSnapshot | null }>(`goal-mode.v2.${sessionID}`, {
658
731
  initial: { goal: null },
659
732
  })
733
+ const persisted = usePersistedGoal(sessionID)
734
+ const messageGoal = createMemo(() => goalFromV2Messages(api.data.session.message.list(sessionID)))
660
735
  const goal = createMemo<GoalSnapshot | null>(() => {
661
- const found = goalFromV2Messages(api.data.session.message.list(sessionID))
736
+ const found = messageGoal()
737
+ if (found === null) return null
738
+ const live = persisted()
739
+ if (live !== undefined) {
740
+ if (found && live && (found.createdAt > live.createdAt || found.updatedAt > live.updatedAt)) return found
741
+ return live
742
+ }
662
743
  return found === undefined ? cache.goal : found
663
744
  })
664
745
  createEffect(() =>
@@ -668,8 +749,9 @@ function GoalSidebarV2(
668
749
  )
669
750
 
670
751
  const [nowSeconds, setNowSeconds] = createSignal(currentEpochSeconds())
752
+ const active = createMemo(() => goal()?.status === "active")
671
753
  createEffect(() => {
672
- if (goal()?.status !== "active") return
754
+ if (!active()) return
673
755
  const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
674
756
  onCleanup(() => clearInterval(timer))
675
757
  })