opencode-goal-plugin 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/goal-plugin.js +414 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.6.4 — 2026-07-12
|
|
4
|
+
|
|
5
|
+
- Re-check session status and recent messages after an auto-continue cooldown, pause immediately for human intervention, Plan-agent switches, permission rejection, aborts, and provider errors, and abort an accepted continuation when the user takes control.
|
|
6
|
+
- Persist one continuation claim per goal execution epoch and source assistant turn to prevent sequential duplicate idle events, while preserving the initiating agent, model, and variant on every continuation prompt.
|
|
7
|
+
|
|
8
|
+
## 0.6.3 — 2026-07-11
|
|
9
|
+
|
|
10
|
+
- Clarify the textual completion protocol so models keep evidence and completion on consecutive plain-text lines instead of wrapping markers in Markdown or separating them with blank lines.
|
|
4
11
|
|
|
5
12
|
## 0.6.2 — 2026-07-11
|
|
6
13
|
|
package/README.md
CHANGED
|
@@ -193,10 +193,10 @@ An ordered sequence, run as a strict pipeline:
|
|
|
193
193
|
## How it works
|
|
194
194
|
|
|
195
195
|
1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn.
|
|
196
|
-
2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done.
|
|
196
|
+
2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. Continuations retain the agent, provider/model, and variant that initiated the goal. Before sending after a cooldown, the plugin re-checks that the session is still idle and no human message, newer assistant turn, Plan-agent switch, rejected permission, abort, or provider error has superseded the request.
|
|
197
197
|
3. The plugin stops auto-continuing when the assistant ends a response with a substantiated `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. A `[goal:complete]` is only honored when it is preceded by a `[goal:evidence]` line; a `[goal:blocked]` is only honored when a concrete blocker is stated. Unsubstantiated claims are rejected and the plugin re-prompts for the missing evidence or blocker.
|
|
198
198
|
4. If OpenCode compacts the session, the plugin injects a deterministic summary into the compaction context so the goal survives the compaction and the assistant keeps the thread. The summary — objective, status, budget usage, recent checkpoints, and recent lifecycle events — is reconstructed from the plugin's persisted goal record rather than from chat memory, so it is stable and reproducible. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation.
|
|
199
|
-
5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction
|
|
199
|
+
5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction, pauses auto-continue, and asks OpenCode to abort an already accepted continuation so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). A durable claim on the source assistant turn also prevents different idle event IDs from sending the same continuation twice. Run `/goal resume` to hand control back to the goal loop.
|
|
200
200
|
|
|
201
201
|
## Completion markers
|
|
202
202
|
|
package/package.json
CHANGED
package/src/goal-plugin.js
CHANGED
|
@@ -87,7 +87,10 @@ function createRuntimeState() {
|
|
|
87
87
|
seenOutputTokens: new Map(),
|
|
88
88
|
activeContinues: new Map(),
|
|
89
89
|
continuationControllers: new Map(),
|
|
90
|
+
promptInFlightSessions: new Set(),
|
|
90
91
|
seenIdleEventIDs: new Set(),
|
|
92
|
+
sessionStatuses: new Map(),
|
|
93
|
+
sessionExecutionContexts: new Map(),
|
|
91
94
|
readOnlyCommandGuards: new Set(),
|
|
92
95
|
ledgerSink: null,
|
|
93
96
|
persistenceLease: null,
|
|
@@ -242,7 +245,13 @@ function makeContinuationPart(text) {
|
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
function getSessionID(event) {
|
|
245
|
-
return
|
|
248
|
+
return (
|
|
249
|
+
event?.properties?.sessionID ||
|
|
250
|
+
event?.properties?.info?.sessionID ||
|
|
251
|
+
event?.data?.sessionID ||
|
|
252
|
+
event?.data?.info?.sessionID ||
|
|
253
|
+
null
|
|
254
|
+
)
|
|
246
255
|
}
|
|
247
256
|
|
|
248
257
|
function isIdleEvent(event) {
|
|
@@ -252,12 +261,76 @@ function isIdleEvent(event) {
|
|
|
252
261
|
)
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
function
|
|
256
|
-
if (
|
|
257
|
-
const
|
|
264
|
+
function normalizeExecutionContext(value) {
|
|
265
|
+
if (!isPlainObject(value)) return null
|
|
266
|
+
const model = isPlainObject(value.model) ? value.model : {}
|
|
267
|
+
const boundedContextText = (candidate) => {
|
|
268
|
+
if (typeof candidate !== "string") return ""
|
|
269
|
+
const normalized = candidate.trim()
|
|
270
|
+
return normalized.length <= MAX_GOAL_META_LENGTH ? normalized : ""
|
|
271
|
+
}
|
|
272
|
+
const agent = boundedContextText(value.agent)
|
|
273
|
+
const providerID = boundedContextText(model.providerID)
|
|
274
|
+
const modelID =
|
|
275
|
+
boundedContextText(model.modelID) || boundedContextText(model.id)
|
|
276
|
+
const variantValue = value.variant ?? model.variant
|
|
277
|
+
const variant = boundedContextText(variantValue)
|
|
278
|
+
if (!agent && !(providerID && modelID) && !variant) return null
|
|
279
|
+
return {
|
|
280
|
+
...(agent ? { agent } : {}),
|
|
281
|
+
...(providerID && modelID ? { model: { providerID, modelID } } : {}),
|
|
282
|
+
...(variant ? { variant } : {}),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function continuationContextInput(goal) {
|
|
287
|
+
const context = normalizeExecutionContext(goal?.executionContext)
|
|
288
|
+
return context ? { ...context } : {}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isPlanAgent(agent) {
|
|
292
|
+
return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function terminalEvent(event) {
|
|
296
|
+
const permissionReply = String(
|
|
297
|
+
event?.properties?.reply ??
|
|
298
|
+
event?.properties?.response ??
|
|
299
|
+
event?.data?.reply ??
|
|
300
|
+
event?.data?.response ??
|
|
301
|
+
"",
|
|
302
|
+
)
|
|
303
|
+
if (event?.type === "permission.replied" && /^(?:reject(?:ed)?|deny|denied)$/i.test(permissionReply)) {
|
|
304
|
+
return {
|
|
305
|
+
sessionID: getSessionID(event),
|
|
306
|
+
stopReason: "permission rejected",
|
|
307
|
+
status: "Goal paused after a permission request was rejected.",
|
|
308
|
+
history: "Paused after OpenCode reported a rejected permission request.",
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let error = null
|
|
313
|
+
if (event?.type === "session.error") {
|
|
314
|
+
error = event?.properties?.error || event?.data?.error
|
|
315
|
+
} else if (event?.type === "message.updated") {
|
|
316
|
+
error = messageInfoFromEvent(event)?.error
|
|
317
|
+
}
|
|
318
|
+
if (!error) return null
|
|
319
|
+
|
|
258
320
|
const name = String(error?.name || error?.data?.name || "")
|
|
259
321
|
const message = String(error?.message || error?.data?.message || "")
|
|
260
|
-
|
|
322
|
+
const aborted = name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
|
|
323
|
+
const summary = summarizeText(`${name}${message ? `: ${message}` : ""}`, 240) || "unknown provider error"
|
|
324
|
+
return {
|
|
325
|
+
sessionID: getSessionID(event) || messageSessionID(messageInfoFromEvent(event)),
|
|
326
|
+
stopReason: aborted ? "user interrupted" : "provider error",
|
|
327
|
+
status: aborted
|
|
328
|
+
? "Goal paused after user interruption."
|
|
329
|
+
: `Goal paused after a terminal provider error: ${summary}`,
|
|
330
|
+
history: aborted
|
|
331
|
+
? "Paused after OpenCode reported that the active turn was aborted."
|
|
332
|
+
: `Paused after OpenCode reported a terminal provider error: ${summary}`,
|
|
333
|
+
}
|
|
261
334
|
}
|
|
262
335
|
|
|
263
336
|
function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
|
|
@@ -706,7 +779,10 @@ function clearRuntimeState() {
|
|
|
706
779
|
seenOutputTokens.clear()
|
|
707
780
|
activeContinues.clear()
|
|
708
781
|
runtime.continuationControllers.clear()
|
|
782
|
+
runtime.promptInFlightSessions.clear()
|
|
709
783
|
runtime.seenIdleEventIDs.clear()
|
|
784
|
+
runtime.sessionStatuses.clear()
|
|
785
|
+
runtime.sessionExecutionContexts.clear()
|
|
710
786
|
runtime.readOnlyCommandGuards.clear()
|
|
711
787
|
}
|
|
712
788
|
|
|
@@ -805,6 +881,7 @@ function resetGoalBudget(goal) {
|
|
|
805
881
|
goal.promptFailures = 0
|
|
806
882
|
goal.formatFailures = 0
|
|
807
883
|
goal.lastAssistantMessageID = ""
|
|
884
|
+
goal.continuationClaim = null
|
|
808
885
|
goal.skipNextTerminalCheck = false
|
|
809
886
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
810
887
|
}
|
|
@@ -1109,6 +1186,18 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
1109
1186
|
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
1110
1187
|
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
1111
1188
|
formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
|
|
1189
|
+
executionContext: normalizeExecutionContext(rawGoal.executionContext),
|
|
1190
|
+
continuationClaim:
|
|
1191
|
+
isPlainObject(rawGoal.continuationClaim) &&
|
|
1192
|
+
typeof rawGoal.continuationClaim.runId === "string" &&
|
|
1193
|
+
rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
|
|
1194
|
+
typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
|
|
1195
|
+
rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
|
|
1196
|
+
? {
|
|
1197
|
+
runId: rawGoal.continuationClaim.runId,
|
|
1198
|
+
sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
|
|
1199
|
+
}
|
|
1200
|
+
: null,
|
|
1112
1201
|
messageIDs: Array.isArray(rawGoal.messageIDs)
|
|
1113
1202
|
? rawGoal.messageIDs.slice(-MAX_MESSAGE_IDS_PER_GOAL).filter((messageID) => typeof messageID === "string" && messageID.length <= MAX_GOAL_META_LENGTH)
|
|
1114
1203
|
: [],
|
|
@@ -1181,6 +1270,9 @@ function deserializeGoal(goal) {
|
|
|
1181
1270
|
"Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
|
|
1182
1271
|
)
|
|
1183
1272
|
}
|
|
1273
|
+
// Recovered goals always require an explicit resume, which starts a fresh
|
|
1274
|
+
// execution epoch and makes any pre-crash continuation claim obsolete.
|
|
1275
|
+
hydrated.continuationClaim = null
|
|
1184
1276
|
|
|
1185
1277
|
return hydrated
|
|
1186
1278
|
}
|
|
@@ -1756,11 +1848,16 @@ function buildContinueMessage(
|
|
|
1756
1848
|
)
|
|
1757
1849
|
} else {
|
|
1758
1850
|
lines.push(
|
|
1759
|
-
"Continue
|
|
1851
|
+
"Continue the next concrete step; inspect and repair failures.",
|
|
1760
1852
|
)
|
|
1761
1853
|
}
|
|
1762
1854
|
|
|
1763
|
-
lines.push(
|
|
1855
|
+
lines.push(
|
|
1856
|
+
"Completion format—consecutive plain lines; no Markdown/backticks/blank line:",
|
|
1857
|
+
"[goal:evidence] <proof>",
|
|
1858
|
+
"[goal:complete]",
|
|
1859
|
+
"Need user input? State why before [goal:blocked].",
|
|
1860
|
+
)
|
|
1764
1861
|
const limitWarning = buildLimitWarning(goal)
|
|
1765
1862
|
if (limitWarning) lines.push(limitWarning.trim())
|
|
1766
1863
|
|
|
@@ -1893,7 +1990,8 @@ function messageRole(message) {
|
|
|
1893
1990
|
}
|
|
1894
1991
|
|
|
1895
1992
|
function messageID(message) {
|
|
1896
|
-
|
|
1993
|
+
const id = message?.info?.id || message?.id || ""
|
|
1994
|
+
return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : ""
|
|
1897
1995
|
}
|
|
1898
1996
|
|
|
1899
1997
|
function messageSessionID(message) {
|
|
@@ -1981,6 +2079,9 @@ function messageInfoFromEvent(event) {
|
|
|
1981
2079
|
event?.properties?.info,
|
|
1982
2080
|
event?.properties?.message?.info,
|
|
1983
2081
|
event?.properties?.message,
|
|
2082
|
+
event?.data?.info,
|
|
2083
|
+
event?.data?.message?.info,
|
|
2084
|
+
event?.data?.message,
|
|
1984
2085
|
]
|
|
1985
2086
|
return candidates.find(isPlainObject) || null
|
|
1986
2087
|
}
|
|
@@ -2044,6 +2145,35 @@ function findLatestAssistantMessage(messages) {
|
|
|
2044
2145
|
return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
|
|
2045
2146
|
}
|
|
2046
2147
|
|
|
2148
|
+
function findLatestExecutionContext(messages) {
|
|
2149
|
+
for (const message of [...(messages || [])].reverse()) {
|
|
2150
|
+
if (messageRole(message) !== "user") continue
|
|
2151
|
+
const info = isPlainObject(message?.info) ? message.info : message
|
|
2152
|
+
const context = normalizeExecutionContext(info)
|
|
2153
|
+
if (context) return context
|
|
2154
|
+
}
|
|
2155
|
+
return null
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function continuationSnapshot(messages) {
|
|
2159
|
+
const list = Array.isArray(messages) ? messages : []
|
|
2160
|
+
const latestAssistant = findLatestAssistantMessage(list)
|
|
2161
|
+
const latestRealUser = [...list]
|
|
2162
|
+
.reverse()
|
|
2163
|
+
.find((message) => messageRole(message) === "user" && !isPluginContinuationMessage(message))
|
|
2164
|
+
const latestRelevant = [...list]
|
|
2165
|
+
.reverse()
|
|
2166
|
+
.find((message) =>
|
|
2167
|
+
(messageRole(message) === "assistant" || messageRole(message) === "user") &&
|
|
2168
|
+
!isPluginContinuationMessage(message),
|
|
2169
|
+
)
|
|
2170
|
+
return {
|
|
2171
|
+
latestAssistantID: messageID(latestAssistant),
|
|
2172
|
+
latestRealUserMessageID: messageID(latestRealUser),
|
|
2173
|
+
latestRelevantMessageID: messageID(latestRelevant),
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2047
2177
|
// The plugin drives auto-continue by sending its own prompts via promptAsync,
|
|
2048
2178
|
// which appear in the session as user-role messages. Every such prompt is
|
|
2049
2179
|
// framed inside <goal_continuation>, so a user message containing that marker
|
|
@@ -2131,6 +2261,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2131
2261
|
stopReason: "",
|
|
2132
2262
|
promptFailures: 0,
|
|
2133
2263
|
formatFailures: 0,
|
|
2264
|
+
executionContext: normalizeExecutionContext(
|
|
2265
|
+
meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
|
|
2266
|
+
),
|
|
2267
|
+
continuationClaim: null,
|
|
2134
2268
|
messageIDs: new Set(),
|
|
2135
2269
|
history: [],
|
|
2136
2270
|
checkpoints: [],
|
|
@@ -2877,6 +3011,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2877
3011
|
|
|
2878
3012
|
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
|
|
2879
3013
|
|
|
3014
|
+
const abortAcceptedContinuation = async (sessionID) => {
|
|
3015
|
+
const runtimeState = currentRuntime()
|
|
3016
|
+
runtimeState.continuationControllers.get(sessionID)?.abort()
|
|
3017
|
+
if (
|
|
3018
|
+
!runtimeState.promptInFlightSessions.has(sessionID) ||
|
|
3019
|
+
typeof client?.session?.abort !== "function"
|
|
3020
|
+
) {
|
|
3021
|
+
return
|
|
3022
|
+
}
|
|
3023
|
+
try {
|
|
3024
|
+
await sessionApi.abort(sessionID)
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
await logPluginError(client, "Failed to abort an accepted auto-continue after intervention", error)
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
const pauseActiveGoal = async (
|
|
3031
|
+
sessionID,
|
|
3032
|
+
{ stopReason: reason, status, history, abortAccepted = false },
|
|
3033
|
+
) => {
|
|
3034
|
+
const goal = goalStates.get(sessionID)
|
|
3035
|
+
if (!goal) return false
|
|
3036
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
3037
|
+
goal.stopped = true
|
|
3038
|
+
goal.stopReason = reason
|
|
3039
|
+
goal.lastStatus = `${status} Run /${commandName} resume to continue.`
|
|
3040
|
+
goal.continuationClaim = null
|
|
3041
|
+
pushHistory(goal, "paused", history)
|
|
3042
|
+
activeContinues.delete(sessionID)
|
|
3043
|
+
await persist()
|
|
3044
|
+
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3045
|
+
return true
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
const claimContinuationSource = async (
|
|
3049
|
+
sessionID,
|
|
3050
|
+
goalID,
|
|
3051
|
+
runID,
|
|
3052
|
+
baselineMessages,
|
|
3053
|
+
{ refreshMessages = false } = {},
|
|
3054
|
+
) => {
|
|
3055
|
+
const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
|
|
3056
|
+
if (!goalBeforeRefresh) return null
|
|
3057
|
+
const hostMessages = refreshMessages
|
|
3058
|
+
? await sessionApi.messages(sessionID, {
|
|
3059
|
+
limit: goalBeforeRefresh.options.maxRecentMessages,
|
|
3060
|
+
})
|
|
3061
|
+
: baselineMessages
|
|
3062
|
+
const goal = activeGoal(sessionID, goalID, runID)
|
|
3063
|
+
if (!goal) return null
|
|
3064
|
+
const messages = Array.isArray(hostMessages)
|
|
3065
|
+
? hostMessages.slice(-goal.options.maxRecentMessages)
|
|
3066
|
+
: []
|
|
3067
|
+
const baseline = continuationSnapshot(baselineMessages)
|
|
3068
|
+
const refreshed = continuationSnapshot(messages)
|
|
3069
|
+
|
|
3070
|
+
if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
|
|
3071
|
+
|
|
3072
|
+
const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
|
|
3073
|
+
if (isPlanAgent(currentContext?.agent)) {
|
|
3074
|
+
await pauseActiveGoal(sessionID, {
|
|
3075
|
+
stopReason: "plan agent active",
|
|
3076
|
+
status: "Auto-continue paused because the active agent switched to Plan.",
|
|
3077
|
+
history: "Paused before auto-continue because the active session agent switched to Plan.",
|
|
3078
|
+
})
|
|
3079
|
+
return null
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const newHumanMessage =
|
|
3083
|
+
refreshed.latestRealUserMessageID &&
|
|
3084
|
+
refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
|
|
3085
|
+
if (newHumanMessage || userInterventionDetected(messages, goal)) {
|
|
3086
|
+
await pauseActiveGoal(sessionID, {
|
|
3087
|
+
stopReason: "user intervention",
|
|
3088
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3089
|
+
history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
|
|
3090
|
+
})
|
|
3091
|
+
return null
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
if (
|
|
3095
|
+
refreshed.latestAssistantID !== baseline.latestAssistantID ||
|
|
3096
|
+
refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
|
|
3097
|
+
) {
|
|
3098
|
+
return null
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
if (!goal.executionContext) {
|
|
3102
|
+
goal.executionContext = findLatestExecutionContext(messages)
|
|
3103
|
+
}
|
|
3104
|
+
const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
|
|
3105
|
+
if (
|
|
3106
|
+
goal.continuationClaim?.runId === runID &&
|
|
3107
|
+
goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
|
|
3108
|
+
) {
|
|
3109
|
+
return null
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
|
|
3113
|
+
const claimPersisted = await persist()
|
|
3114
|
+
if (!claimPersisted && persistenceOptions.persistState) {
|
|
3115
|
+
goal.continuationClaim = null
|
|
3116
|
+
goal.stopped = true
|
|
3117
|
+
goal.stopReason = "continuation claim persistence failed"
|
|
3118
|
+
goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
|
|
3119
|
+
pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
|
|
3120
|
+
return null
|
|
3121
|
+
}
|
|
3122
|
+
return goal
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2880
3125
|
const hooks = {
|
|
2881
3126
|
config: async (config) => {
|
|
2882
3127
|
applyNativeGoalConfig(config, {
|
|
@@ -2885,6 +3130,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2885
3130
|
})
|
|
2886
3131
|
if (pluginOptions.completionAudit) verifierRegistrationReady = true
|
|
2887
3132
|
},
|
|
3133
|
+
"chat.params": async (input) => {
|
|
3134
|
+
if (!input?.sessionID) return
|
|
3135
|
+
const context = normalizeExecutionContext({
|
|
3136
|
+
agent: input.agent,
|
|
3137
|
+
model: input.model,
|
|
3138
|
+
variant: input?.message?.model?.variant,
|
|
3139
|
+
})
|
|
3140
|
+
if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
|
|
3141
|
+
},
|
|
3142
|
+
"chat.message": async (input, output) => {
|
|
3143
|
+
const sessionID = input?.sessionID
|
|
3144
|
+
if (!sessionID) return
|
|
3145
|
+
const context = normalizeExecutionContext(input)
|
|
3146
|
+
if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3147
|
+
|
|
3148
|
+
const message = { role: "user", parts: Array.isArray(output?.parts) ? output.parts : [] }
|
|
3149
|
+
if (isPluginContinuationMessage(message)) return
|
|
3150
|
+
const text = getText(message.parts)
|
|
3151
|
+
const commandPrefix = `/${commandName}`
|
|
3152
|
+
if (text === commandPrefix || text.startsWith(`${commandPrefix} `)) return
|
|
3153
|
+
|
|
3154
|
+
const goal = goalStates.get(sessionID)
|
|
3155
|
+
if (!goal || goal.stopped) return
|
|
3156
|
+
await pauseActiveGoal(sessionID, {
|
|
3157
|
+
stopReason: "user intervention",
|
|
3158
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3159
|
+
history: "Paused immediately when a new human message arrived; latest instruction wins.",
|
|
3160
|
+
abortAccepted: true,
|
|
3161
|
+
})
|
|
3162
|
+
},
|
|
2888
3163
|
"tool.execute.before": async (input) => {
|
|
2889
3164
|
const sessionID = input?.sessionID
|
|
2890
3165
|
if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
@@ -2980,11 +3255,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2980
3255
|
output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
|
|
2981
3256
|
return
|
|
2982
3257
|
}
|
|
3258
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
2983
3259
|
goal.stopped = true
|
|
2984
3260
|
goal.stopReason = "paused"
|
|
2985
3261
|
goal.lastStatus = "Goal paused."
|
|
3262
|
+
goal.continuationClaim = null
|
|
3263
|
+
activeContinues.delete(sessionID)
|
|
2986
3264
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
2987
3265
|
await persist()
|
|
3266
|
+
await abortAcceptedContinuation(sessionID)
|
|
2988
3267
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
2989
3268
|
return
|
|
2990
3269
|
}
|
|
@@ -3046,6 +3325,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3046
3325
|
goal.noProgressTurns = 0
|
|
3047
3326
|
goal.noToolCallTurns = 0
|
|
3048
3327
|
goal.formatFailures = 0
|
|
3328
|
+
goal.continuationClaim = null
|
|
3049
3329
|
goal.lastStatus = "Goal objective updated."
|
|
3050
3330
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3051
3331
|
await persist()
|
|
@@ -3319,17 +3599,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3319
3599
|
},
|
|
3320
3600
|
|
|
3321
3601
|
event: async ({ event }) => {
|
|
3322
|
-
if (
|
|
3602
|
+
if (event?.type === "session.status") {
|
|
3323
3603
|
const sessionID = getSessionID(event)
|
|
3324
|
-
const
|
|
3325
|
-
if (
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3604
|
+
const status = event?.properties?.status?.type || event?.data?.status?.type
|
|
3605
|
+
if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
if (event?.type === "session.updated") {
|
|
3609
|
+
const sessionID = getSessionID(event)
|
|
3610
|
+
const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
|
|
3611
|
+
if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
if (event?.type === "message.updated") {
|
|
3615
|
+
const message = messageInfoFromEvent(event)
|
|
3616
|
+
if (messageRole(message) === "user") {
|
|
3617
|
+
const context = normalizeExecutionContext(message)
|
|
3618
|
+
const sessionID = messageSessionID(message) || getSessionID(event)
|
|
3619
|
+
if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
const terminal = terminalEvent(event)
|
|
3624
|
+
if (terminal?.sessionID) {
|
|
3625
|
+
await pauseActiveGoal(terminal.sessionID, {
|
|
3626
|
+
...terminal,
|
|
3627
|
+
abortAccepted: true,
|
|
3628
|
+
})
|
|
3333
3629
|
return
|
|
3334
3630
|
}
|
|
3335
3631
|
|
|
@@ -3405,6 +3701,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3405
3701
|
if (!isIdleEvent(event)) return
|
|
3406
3702
|
|
|
3407
3703
|
const sessionID = getSessionID(event)
|
|
3704
|
+
// Deprecated session.idle carries no status object but is itself an
|
|
3705
|
+
// authoritative idle signal. Current session.status events were recorded
|
|
3706
|
+
// above before entering this branch.
|
|
3707
|
+
if (event?.type === "session.idle") {
|
|
3708
|
+
currentRuntime().sessionStatuses.set(sessionID, "idle")
|
|
3709
|
+
}
|
|
3408
3710
|
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3409
3711
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
3410
3712
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
@@ -3424,6 +3726,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3424
3726
|
|
|
3425
3727
|
const continueToken = randomUUID()
|
|
3426
3728
|
const continueController = new AbortController()
|
|
3729
|
+
let claimedSourceAssistantMessageID = ""
|
|
3427
3730
|
activeContinues.set(sessionID, continueToken)
|
|
3428
3731
|
currentRuntime().continuationControllers.set(sessionID, continueController)
|
|
3429
3732
|
try {
|
|
@@ -3435,9 +3738,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3435
3738
|
: []
|
|
3436
3739
|
const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
|
|
3437
3740
|
if (!activeGoalAfterMessages) return
|
|
3741
|
+
if (!activeGoalAfterMessages.executionContext) {
|
|
3742
|
+
activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
|
|
3743
|
+
}
|
|
3438
3744
|
|
|
3439
3745
|
const latestAssistant = findLatestAssistantMessage(messages)
|
|
3440
|
-
const latestAssistantID = latestAssistant
|
|
3746
|
+
const latestAssistantID = messageID(latestAssistant)
|
|
3441
3747
|
const latestText = getText(latestAssistant?.parts)
|
|
3442
3748
|
const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
|
|
3443
3749
|
const previousAssistantText = activeGoalAfterMessages.lastAssistantText
|
|
@@ -3457,16 +3763,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3457
3763
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
3458
3764
|
// human. They can /goal resume to hand control back to the plugin.
|
|
3459
3765
|
if (userInterventionDetected(messages, activeGoalAfterMessages)) {
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3766
|
+
await pauseActiveGoal(sessionID, {
|
|
3767
|
+
stopReason: "user intervention",
|
|
3768
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3769
|
+
history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
|
|
3770
|
+
})
|
|
3771
|
+
return
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
|
|
3775
|
+
if (
|
|
3776
|
+
activeGoalAfterMessages.continuationClaim?.runId === runID &&
|
|
3777
|
+
activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
|
|
3778
|
+
sourceAssistantMessageID
|
|
3779
|
+
) {
|
|
3470
3780
|
return
|
|
3471
3781
|
}
|
|
3472
3782
|
|
|
@@ -3607,14 +3917,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3607
3917
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
3608
3918
|
if (limitReason) {
|
|
3609
3919
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3920
|
+
const claimedGoal = await claimContinuationSource(
|
|
3921
|
+
sessionID,
|
|
3922
|
+
goalID,
|
|
3923
|
+
runID,
|
|
3924
|
+
messages,
|
|
3925
|
+
)
|
|
3926
|
+
if (!claimedGoal) return
|
|
3927
|
+
claimedSourceAssistantMessageID =
|
|
3928
|
+
claimedGoal.continuationClaim?.sourceAssistantMessageID || ""
|
|
3929
|
+
claimedGoal.budgetWrapupSent = true
|
|
3930
|
+
claimedGoal.stopped = true
|
|
3931
|
+
claimedGoal.stopReason = limitReason
|
|
3932
|
+
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
3933
|
+
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
3934
|
+
await persist()
|
|
3935
|
+
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
3936
|
+
let response
|
|
3937
|
+
try {
|
|
3938
|
+
response = await sessionApi.promptAsync(sessionID, {
|
|
3939
|
+
...continuationContextInput(claimedGoal),
|
|
3940
|
+
parts: [makeContinuationPart(buildContinueMessage(claimedGoal, { budgetWrapup: true }))],
|
|
3941
|
+
})
|
|
3942
|
+
} finally {
|
|
3943
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
3944
|
+
}
|
|
3945
|
+
if (response?.error) {
|
|
3946
|
+
claimedGoal.lastStatus = `${limitReason}; final handoff request failed: ${response.error.name || "unknown error"}.`
|
|
3947
|
+
pushHistory(claimedGoal, "error", claimedGoal.lastStatus)
|
|
3948
|
+
}
|
|
3618
3949
|
} else {
|
|
3619
3950
|
activeGoalAfterMessages.stopped = true
|
|
3620
3951
|
activeGoalAfterMessages.stopReason = limitReason
|
|
@@ -3736,6 +4067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3736
4067
|
}
|
|
3737
4068
|
|
|
3738
4069
|
const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
|
|
4070
|
+
let cooldownWaited = false
|
|
3739
4071
|
if (
|
|
3740
4072
|
activeGoalAfterMessages.lastContinueAt &&
|
|
3741
4073
|
elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
|
|
@@ -3745,10 +4077,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3745
4077
|
continueController.signal,
|
|
3746
4078
|
)
|
|
3747
4079
|
if (!delayCompleted) return
|
|
4080
|
+
cooldownWaited = true
|
|
3748
4081
|
}
|
|
3749
4082
|
|
|
3750
|
-
const activeGoalBeforePrompt =
|
|
4083
|
+
const activeGoalBeforePrompt = await claimContinuationSource(
|
|
4084
|
+
sessionID,
|
|
4085
|
+
goalID,
|
|
4086
|
+
runID,
|
|
4087
|
+
messages,
|
|
4088
|
+
{ refreshMessages: cooldownWaited },
|
|
4089
|
+
)
|
|
3751
4090
|
if (!activeGoalBeforePrompt) return
|
|
4091
|
+
claimedSourceAssistantMessageID =
|
|
4092
|
+
activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
|
|
3752
4093
|
|
|
3753
4094
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
3754
4095
|
if (budgetWrapup) {
|
|
@@ -3805,22 +4146,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3805
4146
|
}
|
|
3806
4147
|
}
|
|
3807
4148
|
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
4149
|
+
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
4150
|
+
let response
|
|
4151
|
+
try {
|
|
4152
|
+
response = await sessionApi.promptAsync(sessionID, {
|
|
4153
|
+
...continuationContextInput(activeGoalBeforePrompt),
|
|
4154
|
+
parts: [
|
|
4155
|
+
makeContinuationPart(
|
|
4156
|
+
buildContinueMessage(activeGoalBeforePrompt, {
|
|
4157
|
+
budgetWrapup,
|
|
4158
|
+
completionUnverified,
|
|
4159
|
+
blockerUnstated,
|
|
4160
|
+
}),
|
|
4161
|
+
),
|
|
4162
|
+
],
|
|
4163
|
+
})
|
|
4164
|
+
} finally {
|
|
4165
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
4166
|
+
}
|
|
3819
4167
|
|
|
3820
4168
|
if (response.error) {
|
|
3821
4169
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3822
4170
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
3823
|
-
if (
|
|
4171
|
+
if (
|
|
4172
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4173
|
+
claimedSourceAssistantMessageID
|
|
4174
|
+
) {
|
|
4175
|
+
activeGoalAfterPrompt.continuationClaim = null
|
|
3824
4176
|
activeGoalAfterPrompt.promptFailures += 1
|
|
3825
4177
|
activeGoalAfterPrompt.lastStatus = message
|
|
3826
4178
|
pushHistory(activeGoalAfterPrompt, "error", message)
|
|
@@ -3833,7 +4185,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3833
4185
|
await logPluginError(client, message, response.error)
|
|
3834
4186
|
} else {
|
|
3835
4187
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3836
|
-
if (
|
|
4188
|
+
if (
|
|
4189
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4190
|
+
claimedSourceAssistantMessageID
|
|
4191
|
+
) {
|
|
3837
4192
|
// Decrement rather than reset: an alternating error/success pattern
|
|
3838
4193
|
// should still accumulate toward the circuit-breaker cap over time,
|
|
3839
4194
|
// matching the formatFailures approach for the same reason.
|
|
@@ -3851,6 +4206,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3851
4206
|
} catch (error) {
|
|
3852
4207
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
3853
4208
|
if (activeGoalAfterError) {
|
|
4209
|
+
if (
|
|
4210
|
+
claimedSourceAssistantMessageID &&
|
|
4211
|
+
activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
|
|
4212
|
+
claimedSourceAssistantMessageID
|
|
4213
|
+
) {
|
|
4214
|
+
activeGoalAfterError.continuationClaim = null
|
|
4215
|
+
}
|
|
3854
4216
|
activeGoalAfterError.promptFailures += 1
|
|
3855
4217
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
3856
4218
|
activeGoalAfterError.lastStatus = message
|
|
@@ -3864,6 +4226,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3864
4226
|
}
|
|
3865
4227
|
await logPluginError(client, "Auto-continue failed", error)
|
|
3866
4228
|
} finally {
|
|
4229
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
3867
4230
|
// Only delete our own entry. If cleanupGoal already removed it (because
|
|
3868
4231
|
// the goal completed) and a new handler has since set a fresh token,
|
|
3869
4232
|
// we must not clobber the new handler's guard.
|