@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 +9 -2
- package/dist/server.js +117 -33
- package/package.json +2 -1
- package/src/state-path.ts +10 -0
- package/src/tui.ts +114 -32
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3070
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3751
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
4103
|
-
|
|
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.
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
: []),
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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 =
|
|
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 (
|
|
754
|
+
if (!active()) return
|
|
673
755
|
const timer = setInterval(() => setNowSeconds(currentEpochSeconds()), 1000)
|
|
674
756
|
onCleanup(() => clearInterval(timer))
|
|
675
757
|
})
|