opencode-goal-plugin 0.6.3 → 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 +4 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/goal-plugin.js +407 -49
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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.
|
|
4
7
|
|
|
5
8
|
## 0.6.3 — 2026-07-11
|
|
6
9
|
|
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
|
}
|
|
@@ -1898,7 +1990,8 @@ function messageRole(message) {
|
|
|
1898
1990
|
}
|
|
1899
1991
|
|
|
1900
1992
|
function messageID(message) {
|
|
1901
|
-
|
|
1993
|
+
const id = message?.info?.id || message?.id || ""
|
|
1994
|
+
return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : ""
|
|
1902
1995
|
}
|
|
1903
1996
|
|
|
1904
1997
|
function messageSessionID(message) {
|
|
@@ -1986,6 +2079,9 @@ function messageInfoFromEvent(event) {
|
|
|
1986
2079
|
event?.properties?.info,
|
|
1987
2080
|
event?.properties?.message?.info,
|
|
1988
2081
|
event?.properties?.message,
|
|
2082
|
+
event?.data?.info,
|
|
2083
|
+
event?.data?.message?.info,
|
|
2084
|
+
event?.data?.message,
|
|
1989
2085
|
]
|
|
1990
2086
|
return candidates.find(isPlainObject) || null
|
|
1991
2087
|
}
|
|
@@ -2049,6 +2145,35 @@ function findLatestAssistantMessage(messages) {
|
|
|
2049
2145
|
return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
|
|
2050
2146
|
}
|
|
2051
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
|
+
|
|
2052
2177
|
// The plugin drives auto-continue by sending its own prompts via promptAsync,
|
|
2053
2178
|
// which appear in the session as user-role messages. Every such prompt is
|
|
2054
2179
|
// framed inside <goal_continuation>, so a user message containing that marker
|
|
@@ -2136,6 +2261,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2136
2261
|
stopReason: "",
|
|
2137
2262
|
promptFailures: 0,
|
|
2138
2263
|
formatFailures: 0,
|
|
2264
|
+
executionContext: normalizeExecutionContext(
|
|
2265
|
+
meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
|
|
2266
|
+
),
|
|
2267
|
+
continuationClaim: null,
|
|
2139
2268
|
messageIDs: new Set(),
|
|
2140
2269
|
history: [],
|
|
2141
2270
|
checkpoints: [],
|
|
@@ -2882,6 +3011,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2882
3011
|
|
|
2883
3012
|
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
|
|
2884
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
|
+
|
|
2885
3125
|
const hooks = {
|
|
2886
3126
|
config: async (config) => {
|
|
2887
3127
|
applyNativeGoalConfig(config, {
|
|
@@ -2890,6 +3130,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2890
3130
|
})
|
|
2891
3131
|
if (pluginOptions.completionAudit) verifierRegistrationReady = true
|
|
2892
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
|
+
},
|
|
2893
3163
|
"tool.execute.before": async (input) => {
|
|
2894
3164
|
const sessionID = input?.sessionID
|
|
2895
3165
|
if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
@@ -2985,11 +3255,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2985
3255
|
output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
|
|
2986
3256
|
return
|
|
2987
3257
|
}
|
|
3258
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
2988
3259
|
goal.stopped = true
|
|
2989
3260
|
goal.stopReason = "paused"
|
|
2990
3261
|
goal.lastStatus = "Goal paused."
|
|
3262
|
+
goal.continuationClaim = null
|
|
3263
|
+
activeContinues.delete(sessionID)
|
|
2991
3264
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
2992
3265
|
await persist()
|
|
3266
|
+
await abortAcceptedContinuation(sessionID)
|
|
2993
3267
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
2994
3268
|
return
|
|
2995
3269
|
}
|
|
@@ -3051,6 +3325,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3051
3325
|
goal.noProgressTurns = 0
|
|
3052
3326
|
goal.noToolCallTurns = 0
|
|
3053
3327
|
goal.formatFailures = 0
|
|
3328
|
+
goal.continuationClaim = null
|
|
3054
3329
|
goal.lastStatus = "Goal objective updated."
|
|
3055
3330
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3056
3331
|
await persist()
|
|
@@ -3324,17 +3599,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3324
3599
|
},
|
|
3325
3600
|
|
|
3326
3601
|
event: async ({ event }) => {
|
|
3327
|
-
if (
|
|
3602
|
+
if (event?.type === "session.status") {
|
|
3328
3603
|
const sessionID = getSessionID(event)
|
|
3329
|
-
const
|
|
3330
|
-
if (
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
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
|
+
})
|
|
3338
3629
|
return
|
|
3339
3630
|
}
|
|
3340
3631
|
|
|
@@ -3410,6 +3701,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3410
3701
|
if (!isIdleEvent(event)) return
|
|
3411
3702
|
|
|
3412
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
|
+
}
|
|
3413
3710
|
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3414
3711
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
3415
3712
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
@@ -3429,6 +3726,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3429
3726
|
|
|
3430
3727
|
const continueToken = randomUUID()
|
|
3431
3728
|
const continueController = new AbortController()
|
|
3729
|
+
let claimedSourceAssistantMessageID = ""
|
|
3432
3730
|
activeContinues.set(sessionID, continueToken)
|
|
3433
3731
|
currentRuntime().continuationControllers.set(sessionID, continueController)
|
|
3434
3732
|
try {
|
|
@@ -3440,9 +3738,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3440
3738
|
: []
|
|
3441
3739
|
const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
|
|
3442
3740
|
if (!activeGoalAfterMessages) return
|
|
3741
|
+
if (!activeGoalAfterMessages.executionContext) {
|
|
3742
|
+
activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
|
|
3743
|
+
}
|
|
3443
3744
|
|
|
3444
3745
|
const latestAssistant = findLatestAssistantMessage(messages)
|
|
3445
|
-
const latestAssistantID = latestAssistant
|
|
3746
|
+
const latestAssistantID = messageID(latestAssistant)
|
|
3446
3747
|
const latestText = getText(latestAssistant?.parts)
|
|
3447
3748
|
const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
|
|
3448
3749
|
const previousAssistantText = activeGoalAfterMessages.lastAssistantText
|
|
@@ -3462,16 +3763,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3462
3763
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
3463
3764
|
// human. They can /goal resume to hand control back to the plugin.
|
|
3464
3765
|
if (userInterventionDetected(messages, activeGoalAfterMessages)) {
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
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
|
+
) {
|
|
3475
3780
|
return
|
|
3476
3781
|
}
|
|
3477
3782
|
|
|
@@ -3612,14 +3917,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3612
3917
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
3613
3918
|
if (limitReason) {
|
|
3614
3919
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
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
|
+
}
|
|
3623
3949
|
} else {
|
|
3624
3950
|
activeGoalAfterMessages.stopped = true
|
|
3625
3951
|
activeGoalAfterMessages.stopReason = limitReason
|
|
@@ -3741,6 +4067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3741
4067
|
}
|
|
3742
4068
|
|
|
3743
4069
|
const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
|
|
4070
|
+
let cooldownWaited = false
|
|
3744
4071
|
if (
|
|
3745
4072
|
activeGoalAfterMessages.lastContinueAt &&
|
|
3746
4073
|
elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
|
|
@@ -3750,10 +4077,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3750
4077
|
continueController.signal,
|
|
3751
4078
|
)
|
|
3752
4079
|
if (!delayCompleted) return
|
|
4080
|
+
cooldownWaited = true
|
|
3753
4081
|
}
|
|
3754
4082
|
|
|
3755
|
-
const activeGoalBeforePrompt =
|
|
4083
|
+
const activeGoalBeforePrompt = await claimContinuationSource(
|
|
4084
|
+
sessionID,
|
|
4085
|
+
goalID,
|
|
4086
|
+
runID,
|
|
4087
|
+
messages,
|
|
4088
|
+
{ refreshMessages: cooldownWaited },
|
|
4089
|
+
)
|
|
3756
4090
|
if (!activeGoalBeforePrompt) return
|
|
4091
|
+
claimedSourceAssistantMessageID =
|
|
4092
|
+
activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
|
|
3757
4093
|
|
|
3758
4094
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
3759
4095
|
if (budgetWrapup) {
|
|
@@ -3810,22 +4146,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3810
4146
|
}
|
|
3811
4147
|
}
|
|
3812
4148
|
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
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
|
+
}
|
|
3824
4167
|
|
|
3825
4168
|
if (response.error) {
|
|
3826
4169
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3827
4170
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
3828
|
-
if (
|
|
4171
|
+
if (
|
|
4172
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4173
|
+
claimedSourceAssistantMessageID
|
|
4174
|
+
) {
|
|
4175
|
+
activeGoalAfterPrompt.continuationClaim = null
|
|
3829
4176
|
activeGoalAfterPrompt.promptFailures += 1
|
|
3830
4177
|
activeGoalAfterPrompt.lastStatus = message
|
|
3831
4178
|
pushHistory(activeGoalAfterPrompt, "error", message)
|
|
@@ -3838,7 +4185,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3838
4185
|
await logPluginError(client, message, response.error)
|
|
3839
4186
|
} else {
|
|
3840
4187
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3841
|
-
if (
|
|
4188
|
+
if (
|
|
4189
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4190
|
+
claimedSourceAssistantMessageID
|
|
4191
|
+
) {
|
|
3842
4192
|
// Decrement rather than reset: an alternating error/success pattern
|
|
3843
4193
|
// should still accumulate toward the circuit-breaker cap over time,
|
|
3844
4194
|
// matching the formatFailures approach for the same reason.
|
|
@@ -3856,6 +4206,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3856
4206
|
} catch (error) {
|
|
3857
4207
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
3858
4208
|
if (activeGoalAfterError) {
|
|
4209
|
+
if (
|
|
4210
|
+
claimedSourceAssistantMessageID &&
|
|
4211
|
+
activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
|
|
4212
|
+
claimedSourceAssistantMessageID
|
|
4213
|
+
) {
|
|
4214
|
+
activeGoalAfterError.continuationClaim = null
|
|
4215
|
+
}
|
|
3859
4216
|
activeGoalAfterError.promptFailures += 1
|
|
3860
4217
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
3861
4218
|
activeGoalAfterError.lastStatus = message
|
|
@@ -3869,6 +4226,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3869
4226
|
}
|
|
3870
4227
|
await logPluginError(client, "Auto-continue failed", error)
|
|
3871
4228
|
} finally {
|
|
4229
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
3872
4230
|
// Only delete our own entry. If cleanupGoal already removed it (because
|
|
3873
4231
|
// the goal completed) and a new handler has since set a fresh token,
|
|
3874
4232
|
// we must not clobber the new handler's guard.
|