opencode-goal-plugin 0.6.3 → 0.6.5
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 +14 -13
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/goal-plugin.js +428 -64
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.6.5 — 2026-07-12
|
|
4
|
+
|
|
5
|
+
- Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior.
|
|
6
|
+
|
|
7
|
+
## 0.6.4 — 2026-07-12
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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
11
|
|
|
5
12
|
## 0.6.3 — 2026-07-11
|
|
6
13
|
|
package/README.md
CHANGED
|
@@ -36,15 +36,16 @@ surface and versioning expectations.
|
|
|
36
36
|
|
|
37
37
|
### OpenCode version compatibility
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
|
|
40
40
|
|
|
41
41
|
| OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Hook Output Display |
|
|
42
42
|
|---|---|---|---|---|---|
|
|
43
|
+
| 1.17.15 | opencode (`deepseek-v4-flash-free`) | ✅ Canonical tool | ✅ Checkpoint + idle continuation | ✅ Structured `goal_complete` claim | ⚠️ Command text routed to model; mutation guard verified |
|
|
43
44
|
| 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
|
|
44
45
|
| 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
|
|
45
46
|
| 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
|
|
46
47
|
|
|
47
|
-
`/goal status` and auto-continue are graded on **state correctness** (verified directly against
|
|
48
|
+
`/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
|
|
48
49
|
|
|
49
50
|
**Note:** Hook output display depends on OpenCode version — on 1.17.15, `command.execute.before`'s `output.parts` text is not rendered in the TUI for any provider tested; the raw command argument is instead routed to the model as a normal chat turn (see [Limitations](#limitations)). State mutations always work regardless of display: goal creation, flag parsing, auto-continue, limit enforcement, and evidence-gated completion detection were all verified correct via the persisted state file in every combination above. Re-test against your own OpenCode build before relying on unattended runs, and see [`docs/providers.md`](docs/providers.md) for the full per-model marker-compliance notes.
|
|
50
51
|
|
|
@@ -89,7 +90,7 @@ Add success criteria, constraints / non-goals, and a mode:
|
|
|
89
90
|
/goal ship the release --success "tests pass and changelog updated" --constraints "do not touch the public API" --mode ordered
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
`--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered
|
|
93
|
+
`--success` (alias `--success-criteria`) and `--constraints` (alias `--non-goals`) take quoted text and are injected alongside the objective so the assistant keeps them in view. `--mode` is `normal` (default) or `ordered`; `ordered` tells the assistant to preserve step order inside one objective. To queue distinct objectives that auto-promote one at a time, use `/goal sequence`. Multi-word values must be quoted.
|
|
93
94
|
|
|
94
95
|
Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, given a non-positive integer, or (for `--mode`) an unrecognized mode, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text.
|
|
95
96
|
|
|
@@ -125,7 +126,7 @@ Pause without clearing the active goal:
|
|
|
125
126
|
/goal pause
|
|
126
127
|
```
|
|
127
128
|
|
|
128
|
-
Clear the
|
|
129
|
+
Clear all live goals in the current session and discard their saved status:
|
|
129
130
|
|
|
130
131
|
```
|
|
131
132
|
/goal clear
|
|
@@ -155,14 +156,14 @@ A session can hold more than one goal. `/goal <condition>` replaces the focused
|
|
|
155
156
|
/goal focus 1
|
|
156
157
|
```
|
|
157
158
|
|
|
158
|
-
`/goal list` shows
|
|
159
|
+
`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus <number>` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart.
|
|
159
160
|
|
|
160
|
-
#### Ordered
|
|
161
|
+
#### Ordered sequences
|
|
161
162
|
|
|
162
|
-
`/goal
|
|
163
|
+
`/goal sequence` sets up a strict execution queue: separate objectives with `;` or newlines, and the plugin runs them one at a time, focusing the next as soon as the current one completes.
|
|
163
164
|
|
|
164
165
|
```
|
|
165
|
-
/goal
|
|
166
|
+
/goal sequence build the parser; write the tests; ship the release
|
|
166
167
|
```
|
|
167
168
|
|
|
168
169
|
The first goal is focused and the rest are queued. `/goal list` marks the session as ordered. Auto-promotion stops when the sequence is exhausted; `/goal clear` ends the sequence.
|
|
@@ -187,16 +188,16 @@ With success criteria, constraints, and a token budget shorthand:
|
|
|
187
188
|
An ordered sequence, run as a strict pipeline:
|
|
188
189
|
|
|
189
190
|
```
|
|
190
|
-
/goal
|
|
191
|
+
/goal sequence build the parser; write the tests; ship the release
|
|
191
192
|
```
|
|
192
193
|
|
|
193
194
|
## How it works
|
|
194
195
|
|
|
195
196
|
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.
|
|
197
|
+
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
198
|
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
199
|
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
|
|
200
|
+
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
201
|
|
|
201
202
|
## Completion markers
|
|
202
203
|
|
|
@@ -272,7 +273,7 @@ Override any limit for a single goal:
|
|
|
272
273
|
| `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
|
|
273
274
|
| `--success <text>` | Success criteria that define when the goal is satisfied (quote multi-word text) |
|
|
274
275
|
| `--constraints <text>` | Constraints / non-goals to respect (alias `--non-goals`) |
|
|
275
|
-
| `--mode <normal\|ordered>` |
|
|
276
|
+
| `--mode <normal\|ordered>` | Prompt mode for one goal; `ordered` preserves step order inside its objective |
|
|
276
277
|
| `--no-tool-turns <n>` | Consecutive tool-free continuation turns before pausing |
|
|
277
278
|
|
|
278
279
|
Examples:
|
|
@@ -343,7 +344,7 @@ Registered tools:
|
|
|
343
344
|
|
|
344
345
|
`goal_set` and `set_goal` are explicitly constrained to user-requested goals. `goal_complete` accepts a structured claim: a required non-empty `summary`, plus optional criterion/evidence pairs, checks (`passed`, `failed`, or `not-run`), changed files, and known limitations. Failed checks and empty criterion evidence are rejected before archival; accepted claims are serialized deterministically for the configured completion auditor. The legacy `update_goal` tool retains its string `evidence` field for compatibility.
|
|
345
346
|
|
|
346
|
-
These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered
|
|
347
|
+
These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered sequence auto-promotes the next.
|
|
347
348
|
|
|
348
349
|
> Integration note: the tool execute-context shape (`ctx.sessionID`) and the `tool.schema` surface follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but the live registration should be confirmed against a real OpenCode run (see the smoke-test checklist).
|
|
349
350
|
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/goal-plugin.js
CHANGED
|
@@ -74,7 +74,7 @@ const DEFAULT_OPTIONS = {
|
|
|
74
74
|
// handler drives and that the system-prompt transform injects. `sessionGoals`
|
|
75
75
|
// is the full registry of live goals per session (focused + backgrounded);
|
|
76
76
|
// the focused goal is the same object reference held in both. `sessionArchive`
|
|
77
|
-
// keeps a capped list of
|
|
77
|
+
// keeps a capped list of achieved goals so completed work stays readable.
|
|
78
78
|
function createRuntimeState() {
|
|
79
79
|
return {
|
|
80
80
|
goalStates: new Map(),
|
|
@@ -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,
|
|
@@ -126,7 +129,7 @@ function runtimeCollection(name) {
|
|
|
126
129
|
const goalStates = runtimeCollection("goalStates")
|
|
127
130
|
const sessionGoals = runtimeCollection("sessionGoals")
|
|
128
131
|
const sessionArchive = runtimeCollection("sessionArchive")
|
|
129
|
-
// Sessions running an ordered
|
|
132
|
+
// Sessions running an ordered sequence: when the focused goal
|
|
130
133
|
// completes, the next live goal (in creation order) is auto-promoted to focus
|
|
131
134
|
// so the sequence advances on its own.
|
|
132
135
|
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
@@ -144,6 +147,9 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
|
144
147
|
const activeContinues = runtimeCollection("activeContinues")
|
|
145
148
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
146
149
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
150
|
+
// `sequence` is canonical. The former public spelling remains accepted at
|
|
151
|
+
// the parser boundary so existing scripts do not break.
|
|
152
|
+
const SEQUENCE_COMMANDS = ["sequence", "sisyphus"]
|
|
147
153
|
const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
|
|
148
154
|
const GOAL_FLAG_SPECS = {
|
|
149
155
|
"--max-turns": {
|
|
@@ -208,8 +214,8 @@ function messageHasToolCall(message) {
|
|
|
208
214
|
|
|
209
215
|
const GOAL_MODES = new Set(["normal", "ordered"])
|
|
210
216
|
|
|
211
|
-
// Goal mode: normal vs ordered
|
|
212
|
-
//
|
|
217
|
+
// Goal mode: normal vs ordered. The former public spelling remains accepted
|
|
218
|
+
// as an input alias, while stored state and output always use `ordered`.
|
|
213
219
|
// Returns the canonical mode or null when unrecognized.
|
|
214
220
|
function normalizeMode(value) {
|
|
215
221
|
const normalized = String(value || "").trim().toLowerCase()
|
|
@@ -242,7 +248,13 @@ function makeContinuationPart(text) {
|
|
|
242
248
|
}
|
|
243
249
|
|
|
244
250
|
function getSessionID(event) {
|
|
245
|
-
return
|
|
251
|
+
return (
|
|
252
|
+
event?.properties?.sessionID ||
|
|
253
|
+
event?.properties?.info?.sessionID ||
|
|
254
|
+
event?.data?.sessionID ||
|
|
255
|
+
event?.data?.info?.sessionID ||
|
|
256
|
+
null
|
|
257
|
+
)
|
|
246
258
|
}
|
|
247
259
|
|
|
248
260
|
function isIdleEvent(event) {
|
|
@@ -252,12 +264,76 @@ function isIdleEvent(event) {
|
|
|
252
264
|
)
|
|
253
265
|
}
|
|
254
266
|
|
|
255
|
-
function
|
|
256
|
-
if (
|
|
257
|
-
const
|
|
267
|
+
function normalizeExecutionContext(value) {
|
|
268
|
+
if (!isPlainObject(value)) return null
|
|
269
|
+
const model = isPlainObject(value.model) ? value.model : {}
|
|
270
|
+
const boundedContextText = (candidate) => {
|
|
271
|
+
if (typeof candidate !== "string") return ""
|
|
272
|
+
const normalized = candidate.trim()
|
|
273
|
+
return normalized.length <= MAX_GOAL_META_LENGTH ? normalized : ""
|
|
274
|
+
}
|
|
275
|
+
const agent = boundedContextText(value.agent)
|
|
276
|
+
const providerID = boundedContextText(model.providerID)
|
|
277
|
+
const modelID =
|
|
278
|
+
boundedContextText(model.modelID) || boundedContextText(model.id)
|
|
279
|
+
const variantValue = value.variant ?? model.variant
|
|
280
|
+
const variant = boundedContextText(variantValue)
|
|
281
|
+
if (!agent && !(providerID && modelID) && !variant) return null
|
|
282
|
+
return {
|
|
283
|
+
...(agent ? { agent } : {}),
|
|
284
|
+
...(providerID && modelID ? { model: { providerID, modelID } } : {}),
|
|
285
|
+
...(variant ? { variant } : {}),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function continuationContextInput(goal) {
|
|
290
|
+
const context = normalizeExecutionContext(goal?.executionContext)
|
|
291
|
+
return context ? { ...context } : {}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isPlanAgent(agent) {
|
|
295
|
+
return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function terminalEvent(event) {
|
|
299
|
+
const permissionReply = String(
|
|
300
|
+
event?.properties?.reply ??
|
|
301
|
+
event?.properties?.response ??
|
|
302
|
+
event?.data?.reply ??
|
|
303
|
+
event?.data?.response ??
|
|
304
|
+
"",
|
|
305
|
+
)
|
|
306
|
+
if (event?.type === "permission.replied" && /^(?:reject(?:ed)?|deny|denied)$/i.test(permissionReply)) {
|
|
307
|
+
return {
|
|
308
|
+
sessionID: getSessionID(event),
|
|
309
|
+
stopReason: "permission rejected",
|
|
310
|
+
status: "Goal paused after a permission request was rejected.",
|
|
311
|
+
history: "Paused after OpenCode reported a rejected permission request.",
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let error = null
|
|
316
|
+
if (event?.type === "session.error") {
|
|
317
|
+
error = event?.properties?.error || event?.data?.error
|
|
318
|
+
} else if (event?.type === "message.updated") {
|
|
319
|
+
error = messageInfoFromEvent(event)?.error
|
|
320
|
+
}
|
|
321
|
+
if (!error) return null
|
|
322
|
+
|
|
258
323
|
const name = String(error?.name || error?.data?.name || "")
|
|
259
324
|
const message = String(error?.message || error?.data?.message || "")
|
|
260
|
-
|
|
325
|
+
const aborted = name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
|
|
326
|
+
const summary = summarizeText(`${name}${message ? `: ${message}` : ""}`, 240) || "unknown provider error"
|
|
327
|
+
return {
|
|
328
|
+
sessionID: getSessionID(event) || messageSessionID(messageInfoFromEvent(event)),
|
|
329
|
+
stopReason: aborted ? "user interrupted" : "provider error",
|
|
330
|
+
status: aborted
|
|
331
|
+
? "Goal paused after user interruption."
|
|
332
|
+
: `Goal paused after a terminal provider error: ${summary}`,
|
|
333
|
+
history: aborted
|
|
334
|
+
? "Paused after OpenCode reported that the active turn was aborted."
|
|
335
|
+
: `Paused after OpenCode reported a terminal provider error: ${summary}`,
|
|
336
|
+
}
|
|
261
337
|
}
|
|
262
338
|
|
|
263
339
|
function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
|
|
@@ -654,7 +730,7 @@ function archiveSessionResult(sessionID, result) {
|
|
|
654
730
|
sessionArchive.set(sessionID, list.slice(-MAX_ARCHIVED_PER_SESSION))
|
|
655
731
|
}
|
|
656
732
|
|
|
657
|
-
// Advance an ordered
|
|
733
|
+
// Advance an ordered sequence: focus the next live goal in creation
|
|
658
734
|
// order, clearing any backgrounded state so the idle handler drives it. Returns
|
|
659
735
|
// the promoted goal, or null when the sequence is exhausted (which also clears
|
|
660
736
|
// the session's ordered flag).
|
|
@@ -670,7 +746,7 @@ function promoteNextOrderedGoal(sessionID) {
|
|
|
670
746
|
resumeGoalClock(next)
|
|
671
747
|
next.skipNextTerminalCheck = true
|
|
672
748
|
next.lastStatus = "Promoted as the next ordered goal."
|
|
673
|
-
pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered
|
|
749
|
+
pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered sequence.")
|
|
674
750
|
focusGoal(sessionID, next)
|
|
675
751
|
return next
|
|
676
752
|
}
|
|
@@ -706,7 +782,10 @@ function clearRuntimeState() {
|
|
|
706
782
|
seenOutputTokens.clear()
|
|
707
783
|
activeContinues.clear()
|
|
708
784
|
runtime.continuationControllers.clear()
|
|
785
|
+
runtime.promptInFlightSessions.clear()
|
|
709
786
|
runtime.seenIdleEventIDs.clear()
|
|
787
|
+
runtime.sessionStatuses.clear()
|
|
788
|
+
runtime.sessionExecutionContexts.clear()
|
|
710
789
|
runtime.readOnlyCommandGuards.clear()
|
|
711
790
|
}
|
|
712
791
|
|
|
@@ -805,6 +884,7 @@ function resetGoalBudget(goal) {
|
|
|
805
884
|
goal.promptFailures = 0
|
|
806
885
|
goal.formatFailures = 0
|
|
807
886
|
goal.lastAssistantMessageID = ""
|
|
887
|
+
goal.continuationClaim = null
|
|
808
888
|
goal.skipNextTerminalCheck = false
|
|
809
889
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
810
890
|
}
|
|
@@ -1109,6 +1189,18 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
1109
1189
|
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
1110
1190
|
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
1111
1191
|
formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
|
|
1192
|
+
executionContext: normalizeExecutionContext(rawGoal.executionContext),
|
|
1193
|
+
continuationClaim:
|
|
1194
|
+
isPlainObject(rawGoal.continuationClaim) &&
|
|
1195
|
+
typeof rawGoal.continuationClaim.runId === "string" &&
|
|
1196
|
+
rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
|
|
1197
|
+
typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
|
|
1198
|
+
rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
|
|
1199
|
+
? {
|
|
1200
|
+
runId: rawGoal.continuationClaim.runId,
|
|
1201
|
+
sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
|
|
1202
|
+
}
|
|
1203
|
+
: null,
|
|
1112
1204
|
messageIDs: Array.isArray(rawGoal.messageIDs)
|
|
1113
1205
|
? rawGoal.messageIDs.slice(-MAX_MESSAGE_IDS_PER_GOAL).filter((messageID) => typeof messageID === "string" && messageID.length <= MAX_GOAL_META_LENGTH)
|
|
1114
1206
|
: [],
|
|
@@ -1181,6 +1273,9 @@ function deserializeGoal(goal) {
|
|
|
1181
1273
|
"Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
|
|
1182
1274
|
)
|
|
1183
1275
|
}
|
|
1276
|
+
// Recovered goals always require an explicit resume, which starts a fresh
|
|
1277
|
+
// execution epoch and makes any pre-crash continuation claim obsolete.
|
|
1278
|
+
hydrated.continuationClaim = null
|
|
1184
1279
|
|
|
1185
1280
|
return hydrated
|
|
1186
1281
|
}
|
|
@@ -1898,7 +1993,8 @@ function messageRole(message) {
|
|
|
1898
1993
|
}
|
|
1899
1994
|
|
|
1900
1995
|
function messageID(message) {
|
|
1901
|
-
|
|
1996
|
+
const id = message?.info?.id || message?.id || ""
|
|
1997
|
+
return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : ""
|
|
1902
1998
|
}
|
|
1903
1999
|
|
|
1904
2000
|
function messageSessionID(message) {
|
|
@@ -1986,6 +2082,9 @@ function messageInfoFromEvent(event) {
|
|
|
1986
2082
|
event?.properties?.info,
|
|
1987
2083
|
event?.properties?.message?.info,
|
|
1988
2084
|
event?.properties?.message,
|
|
2085
|
+
event?.data?.info,
|
|
2086
|
+
event?.data?.message?.info,
|
|
2087
|
+
event?.data?.message,
|
|
1989
2088
|
]
|
|
1990
2089
|
return candidates.find(isPlainObject) || null
|
|
1991
2090
|
}
|
|
@@ -2049,6 +2148,35 @@ function findLatestAssistantMessage(messages) {
|
|
|
2049
2148
|
return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
|
|
2050
2149
|
}
|
|
2051
2150
|
|
|
2151
|
+
function findLatestExecutionContext(messages) {
|
|
2152
|
+
for (const message of [...(messages || [])].reverse()) {
|
|
2153
|
+
if (messageRole(message) !== "user") continue
|
|
2154
|
+
const info = isPlainObject(message?.info) ? message.info : message
|
|
2155
|
+
const context = normalizeExecutionContext(info)
|
|
2156
|
+
if (context) return context
|
|
2157
|
+
}
|
|
2158
|
+
return null
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function continuationSnapshot(messages) {
|
|
2162
|
+
const list = Array.isArray(messages) ? messages : []
|
|
2163
|
+
const latestAssistant = findLatestAssistantMessage(list)
|
|
2164
|
+
const latestRealUser = [...list]
|
|
2165
|
+
.reverse()
|
|
2166
|
+
.find((message) => messageRole(message) === "user" && !isPluginContinuationMessage(message))
|
|
2167
|
+
const latestRelevant = [...list]
|
|
2168
|
+
.reverse()
|
|
2169
|
+
.find((message) =>
|
|
2170
|
+
(messageRole(message) === "assistant" || messageRole(message) === "user") &&
|
|
2171
|
+
!isPluginContinuationMessage(message),
|
|
2172
|
+
)
|
|
2173
|
+
return {
|
|
2174
|
+
latestAssistantID: messageID(latestAssistant),
|
|
2175
|
+
latestRealUserMessageID: messageID(latestRealUser),
|
|
2176
|
+
latestRelevantMessageID: messageID(latestRelevant),
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2052
2180
|
// The plugin drives auto-continue by sending its own prompts via promptAsync,
|
|
2053
2181
|
// which appear in the session as user-role messages. Every such prompt is
|
|
2054
2182
|
// framed inside <goal_continuation>, so a user message containing that marker
|
|
@@ -2136,6 +2264,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2136
2264
|
stopReason: "",
|
|
2137
2265
|
promptFailures: 0,
|
|
2138
2266
|
formatFailures: 0,
|
|
2267
|
+
executionContext: normalizeExecutionContext(
|
|
2268
|
+
meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
|
|
2269
|
+
),
|
|
2270
|
+
continuationClaim: null,
|
|
2139
2271
|
messageIDs: new Set(),
|
|
2140
2272
|
history: [],
|
|
2141
2273
|
checkpoints: [],
|
|
@@ -2334,7 +2466,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2334
2466
|
const ordered = sessionOrdered.has(sessionID)
|
|
2335
2467
|
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
2336
2468
|
cleanupGoal(sessionID)
|
|
2337
|
-
// Advance an ordered
|
|
2469
|
+
// Advance an ordered sequence just like the marker path does.
|
|
2338
2470
|
if (ordered) promoteNextOrderedGoal(sessionID)
|
|
2339
2471
|
const durable = await persistFinal("completion", ledgerDurable)
|
|
2340
2472
|
if (durable === false) {
|
|
@@ -2578,7 +2710,7 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
2578
2710
|
|
|
2579
2711
|
const lines = []
|
|
2580
2712
|
if (goals.length) {
|
|
2581
|
-
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered
|
|
2713
|
+
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
|
|
2582
2714
|
goals.forEach((goal, index) => {
|
|
2583
2715
|
const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
|
|
2584
2716
|
const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
|
|
@@ -2882,6 +3014,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2882
3014
|
|
|
2883
3015
|
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
|
|
2884
3016
|
|
|
3017
|
+
const abortAcceptedContinuation = async (sessionID) => {
|
|
3018
|
+
const runtimeState = currentRuntime()
|
|
3019
|
+
runtimeState.continuationControllers.get(sessionID)?.abort()
|
|
3020
|
+
if (
|
|
3021
|
+
!runtimeState.promptInFlightSessions.has(sessionID) ||
|
|
3022
|
+
typeof client?.session?.abort !== "function"
|
|
3023
|
+
) {
|
|
3024
|
+
return
|
|
3025
|
+
}
|
|
3026
|
+
try {
|
|
3027
|
+
await sessionApi.abort(sessionID)
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
await logPluginError(client, "Failed to abort an accepted auto-continue after intervention", error)
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
const pauseActiveGoal = async (
|
|
3034
|
+
sessionID,
|
|
3035
|
+
{ stopReason: reason, status, history, abortAccepted = false },
|
|
3036
|
+
) => {
|
|
3037
|
+
const goal = goalStates.get(sessionID)
|
|
3038
|
+
if (!goal) return false
|
|
3039
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
3040
|
+
goal.stopped = true
|
|
3041
|
+
goal.stopReason = reason
|
|
3042
|
+
goal.lastStatus = `${status} Run /${commandName} resume to continue.`
|
|
3043
|
+
goal.continuationClaim = null
|
|
3044
|
+
pushHistory(goal, "paused", history)
|
|
3045
|
+
activeContinues.delete(sessionID)
|
|
3046
|
+
await persist()
|
|
3047
|
+
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3048
|
+
return true
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
const claimContinuationSource = async (
|
|
3052
|
+
sessionID,
|
|
3053
|
+
goalID,
|
|
3054
|
+
runID,
|
|
3055
|
+
baselineMessages,
|
|
3056
|
+
{ refreshMessages = false } = {},
|
|
3057
|
+
) => {
|
|
3058
|
+
const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
|
|
3059
|
+
if (!goalBeforeRefresh) return null
|
|
3060
|
+
const hostMessages = refreshMessages
|
|
3061
|
+
? await sessionApi.messages(sessionID, {
|
|
3062
|
+
limit: goalBeforeRefresh.options.maxRecentMessages,
|
|
3063
|
+
})
|
|
3064
|
+
: baselineMessages
|
|
3065
|
+
const goal = activeGoal(sessionID, goalID, runID)
|
|
3066
|
+
if (!goal) return null
|
|
3067
|
+
const messages = Array.isArray(hostMessages)
|
|
3068
|
+
? hostMessages.slice(-goal.options.maxRecentMessages)
|
|
3069
|
+
: []
|
|
3070
|
+
const baseline = continuationSnapshot(baselineMessages)
|
|
3071
|
+
const refreshed = continuationSnapshot(messages)
|
|
3072
|
+
|
|
3073
|
+
if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
|
|
3074
|
+
|
|
3075
|
+
const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
|
|
3076
|
+
if (isPlanAgent(currentContext?.agent)) {
|
|
3077
|
+
await pauseActiveGoal(sessionID, {
|
|
3078
|
+
stopReason: "plan agent active",
|
|
3079
|
+
status: "Auto-continue paused because the active agent switched to Plan.",
|
|
3080
|
+
history: "Paused before auto-continue because the active session agent switched to Plan.",
|
|
3081
|
+
})
|
|
3082
|
+
return null
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
const newHumanMessage =
|
|
3086
|
+
refreshed.latestRealUserMessageID &&
|
|
3087
|
+
refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
|
|
3088
|
+
if (newHumanMessage || userInterventionDetected(messages, goal)) {
|
|
3089
|
+
await pauseActiveGoal(sessionID, {
|
|
3090
|
+
stopReason: "user intervention",
|
|
3091
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3092
|
+
history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
|
|
3093
|
+
})
|
|
3094
|
+
return null
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
if (
|
|
3098
|
+
refreshed.latestAssistantID !== baseline.latestAssistantID ||
|
|
3099
|
+
refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
|
|
3100
|
+
) {
|
|
3101
|
+
return null
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
if (!goal.executionContext) {
|
|
3105
|
+
goal.executionContext = findLatestExecutionContext(messages)
|
|
3106
|
+
}
|
|
3107
|
+
const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
|
|
3108
|
+
if (
|
|
3109
|
+
goal.continuationClaim?.runId === runID &&
|
|
3110
|
+
goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
|
|
3111
|
+
) {
|
|
3112
|
+
return null
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
|
|
3116
|
+
const claimPersisted = await persist()
|
|
3117
|
+
if (!claimPersisted && persistenceOptions.persistState) {
|
|
3118
|
+
goal.continuationClaim = null
|
|
3119
|
+
goal.stopped = true
|
|
3120
|
+
goal.stopReason = "continuation claim persistence failed"
|
|
3121
|
+
goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
|
|
3122
|
+
pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
|
|
3123
|
+
return null
|
|
3124
|
+
}
|
|
3125
|
+
return goal
|
|
3126
|
+
}
|
|
3127
|
+
|
|
2885
3128
|
const hooks = {
|
|
2886
3129
|
config: async (config) => {
|
|
2887
3130
|
applyNativeGoalConfig(config, {
|
|
@@ -2890,6 +3133,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2890
3133
|
})
|
|
2891
3134
|
if (pluginOptions.completionAudit) verifierRegistrationReady = true
|
|
2892
3135
|
},
|
|
3136
|
+
"chat.params": async (input) => {
|
|
3137
|
+
if (!input?.sessionID) return
|
|
3138
|
+
const context = normalizeExecutionContext({
|
|
3139
|
+
agent: input.agent,
|
|
3140
|
+
model: input.model,
|
|
3141
|
+
variant: input?.message?.model?.variant,
|
|
3142
|
+
})
|
|
3143
|
+
if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
|
|
3144
|
+
},
|
|
3145
|
+
"chat.message": async (input, output) => {
|
|
3146
|
+
const sessionID = input?.sessionID
|
|
3147
|
+
if (!sessionID) return
|
|
3148
|
+
const context = normalizeExecutionContext(input)
|
|
3149
|
+
if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3150
|
+
|
|
3151
|
+
const message = { role: "user", parts: Array.isArray(output?.parts) ? output.parts : [] }
|
|
3152
|
+
if (isPluginContinuationMessage(message)) return
|
|
3153
|
+
const text = getText(message.parts)
|
|
3154
|
+
const commandPrefix = `/${commandName}`
|
|
3155
|
+
if (text === commandPrefix || text.startsWith(`${commandPrefix} `)) return
|
|
3156
|
+
|
|
3157
|
+
const goal = goalStates.get(sessionID)
|
|
3158
|
+
if (!goal || goal.stopped) return
|
|
3159
|
+
await pauseActiveGoal(sessionID, {
|
|
3160
|
+
stopReason: "user intervention",
|
|
3161
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3162
|
+
history: "Paused immediately when a new human message arrived; latest instruction wins.",
|
|
3163
|
+
abortAccepted: true,
|
|
3164
|
+
})
|
|
3165
|
+
},
|
|
2893
3166
|
"tool.execute.before": async (input) => {
|
|
2894
3167
|
const sessionID = input?.sessionID
|
|
2895
3168
|
if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
@@ -2985,11 +3258,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2985
3258
|
output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
|
|
2986
3259
|
return
|
|
2987
3260
|
}
|
|
3261
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
2988
3262
|
goal.stopped = true
|
|
2989
3263
|
goal.stopReason = "paused"
|
|
2990
3264
|
goal.lastStatus = "Goal paused."
|
|
3265
|
+
goal.continuationClaim = null
|
|
3266
|
+
activeContinues.delete(sessionID)
|
|
2991
3267
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
2992
3268
|
await persist()
|
|
3269
|
+
await abortAcceptedContinuation(sessionID)
|
|
2993
3270
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
2994
3271
|
return
|
|
2995
3272
|
}
|
|
@@ -3051,6 +3328,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3051
3328
|
goal.noProgressTurns = 0
|
|
3052
3329
|
goal.noToolCallTurns = 0
|
|
3053
3330
|
goal.formatFailures = 0
|
|
3331
|
+
goal.continuationClaim = null
|
|
3054
3332
|
goal.lastStatus = "Goal objective updated."
|
|
3055
3333
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3056
3334
|
await persist()
|
|
@@ -3072,8 +3350,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3072
3350
|
return
|
|
3073
3351
|
}
|
|
3074
3352
|
|
|
3075
|
-
|
|
3076
|
-
|
|
3353
|
+
const sequenceCommand = SEQUENCE_COMMANDS.find(
|
|
3354
|
+
(command) => args.toLowerCase() === command || args.toLowerCase().startsWith(`${command} `),
|
|
3355
|
+
)
|
|
3356
|
+
if (sequenceCommand) {
|
|
3357
|
+
const rest = args.slice(sequenceCommand.length).trim()
|
|
3077
3358
|
const objectives = rest
|
|
3078
3359
|
.split(/\n|;/)
|
|
3079
3360
|
.map((part) => stripWrappingQuotes(part.trim()))
|
|
@@ -3081,7 +3362,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3081
3362
|
if (!objectives.length) {
|
|
3082
3363
|
output.parts = [
|
|
3083
3364
|
makeTextPart(
|
|
3084
|
-
`No objectives provided. Use \`/${commandName}
|
|
3365
|
+
`No objectives provided. Use \`/${commandName} sequence <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
|
|
3085
3366
|
),
|
|
3086
3367
|
]
|
|
3087
3368
|
return
|
|
@@ -3125,7 +3406,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3125
3406
|
pushHistory(
|
|
3126
3407
|
created,
|
|
3127
3408
|
"set",
|
|
3128
|
-
`Ordered goal ${index + 1}/${objectives.length} created
|
|
3409
|
+
`Ordered goal ${index + 1}/${objectives.length} created.`,
|
|
3129
3410
|
)
|
|
3130
3411
|
registerSessionGoal(created)
|
|
3131
3412
|
})
|
|
@@ -3135,7 +3416,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3135
3416
|
output.parts = [
|
|
3136
3417
|
makeTextPart(
|
|
3137
3418
|
[
|
|
3138
|
-
`Started an ordered sequence of ${objectives.length} goal(s)
|
|
3419
|
+
`Started an ordered sequence of ${objectives.length} goal(s):`,
|
|
3139
3420
|
...objectives.map((objective, index) => `${index + 1}. ${objective}`),
|
|
3140
3421
|
"",
|
|
3141
3422
|
`Focused goal 1: ${firstGoal.condition}`,
|
|
@@ -3285,7 +3566,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3285
3566
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
3286
3567
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
3287
3568
|
// goal and add another. Clear any ordered-sequence flag so the new
|
|
3288
|
-
// standalone goal does not trigger
|
|
3569
|
+
// standalone goal does not trigger auto-promotion of the old sequence
|
|
3289
3570
|
// goals that may still be in the registry (matches the agent setGoal path).
|
|
3290
3571
|
sessionOrdered.delete(sessionID)
|
|
3291
3572
|
cleanupGoal(sessionID)
|
|
@@ -3324,17 +3605,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3324
3605
|
},
|
|
3325
3606
|
|
|
3326
3607
|
event: async ({ event }) => {
|
|
3327
|
-
if (
|
|
3608
|
+
if (event?.type === "session.status") {
|
|
3328
3609
|
const sessionID = getSessionID(event)
|
|
3329
|
-
const
|
|
3330
|
-
if (
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3610
|
+
const status = event?.properties?.status?.type || event?.data?.status?.type
|
|
3611
|
+
if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
if (event?.type === "session.updated") {
|
|
3615
|
+
const sessionID = getSessionID(event)
|
|
3616
|
+
const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
|
|
3617
|
+
if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
if (event?.type === "message.updated") {
|
|
3621
|
+
const message = messageInfoFromEvent(event)
|
|
3622
|
+
if (messageRole(message) === "user") {
|
|
3623
|
+
const context = normalizeExecutionContext(message)
|
|
3624
|
+
const sessionID = messageSessionID(message) || getSessionID(event)
|
|
3625
|
+
if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
const terminal = terminalEvent(event)
|
|
3630
|
+
if (terminal?.sessionID) {
|
|
3631
|
+
await pauseActiveGoal(terminal.sessionID, {
|
|
3632
|
+
...terminal,
|
|
3633
|
+
abortAccepted: true,
|
|
3634
|
+
})
|
|
3338
3635
|
return
|
|
3339
3636
|
}
|
|
3340
3637
|
|
|
@@ -3410,6 +3707,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3410
3707
|
if (!isIdleEvent(event)) return
|
|
3411
3708
|
|
|
3412
3709
|
const sessionID = getSessionID(event)
|
|
3710
|
+
// Deprecated session.idle carries no status object but is itself an
|
|
3711
|
+
// authoritative idle signal. Current session.status events were recorded
|
|
3712
|
+
// above before entering this branch.
|
|
3713
|
+
if (event?.type === "session.idle") {
|
|
3714
|
+
currentRuntime().sessionStatuses.set(sessionID, "idle")
|
|
3715
|
+
}
|
|
3413
3716
|
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3414
3717
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
3415
3718
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
@@ -3429,6 +3732,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3429
3732
|
|
|
3430
3733
|
const continueToken = randomUUID()
|
|
3431
3734
|
const continueController = new AbortController()
|
|
3735
|
+
let claimedSourceAssistantMessageID = ""
|
|
3432
3736
|
activeContinues.set(sessionID, continueToken)
|
|
3433
3737
|
currentRuntime().continuationControllers.set(sessionID, continueController)
|
|
3434
3738
|
try {
|
|
@@ -3440,9 +3744,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3440
3744
|
: []
|
|
3441
3745
|
const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
|
|
3442
3746
|
if (!activeGoalAfterMessages) return
|
|
3747
|
+
if (!activeGoalAfterMessages.executionContext) {
|
|
3748
|
+
activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
|
|
3749
|
+
}
|
|
3443
3750
|
|
|
3444
3751
|
const latestAssistant = findLatestAssistantMessage(messages)
|
|
3445
|
-
const latestAssistantID = latestAssistant
|
|
3752
|
+
const latestAssistantID = messageID(latestAssistant)
|
|
3446
3753
|
const latestText = getText(latestAssistant?.parts)
|
|
3447
3754
|
const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
|
|
3448
3755
|
const previousAssistantText = activeGoalAfterMessages.lastAssistantText
|
|
@@ -3462,16 +3769,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3462
3769
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
3463
3770
|
// human. They can /goal resume to hand control back to the plugin.
|
|
3464
3771
|
if (userInterventionDetected(messages, activeGoalAfterMessages)) {
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3772
|
+
await pauseActiveGoal(sessionID, {
|
|
3773
|
+
stopReason: "user intervention",
|
|
3774
|
+
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
3775
|
+
history: "Paused auto-continue after a real user message arrived; latest instruction wins.",
|
|
3776
|
+
})
|
|
3777
|
+
return
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
|
|
3781
|
+
if (
|
|
3782
|
+
activeGoalAfterMessages.continuationClaim?.runId === runID &&
|
|
3783
|
+
activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
|
|
3784
|
+
sourceAssistantMessageID
|
|
3785
|
+
) {
|
|
3475
3786
|
return
|
|
3476
3787
|
}
|
|
3477
3788
|
|
|
@@ -3547,7 +3858,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3547
3858
|
const ordered = sessionOrdered.has(sessionID)
|
|
3548
3859
|
rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
|
|
3549
3860
|
cleanupGoal(sessionID)
|
|
3550
|
-
// Ordered
|
|
3861
|
+
// Ordered sequence: auto-promote the next goal so the
|
|
3551
3862
|
// session keeps working through the sequence without manual /goal focus.
|
|
3552
3863
|
if (ordered) {
|
|
3553
3864
|
promoteNextOrderedGoal(sessionID)
|
|
@@ -3612,14 +3923,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3612
3923
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
3613
3924
|
if (limitReason) {
|
|
3614
3925
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3926
|
+
const claimedGoal = await claimContinuationSource(
|
|
3927
|
+
sessionID,
|
|
3928
|
+
goalID,
|
|
3929
|
+
runID,
|
|
3930
|
+
messages,
|
|
3931
|
+
)
|
|
3932
|
+
if (!claimedGoal) return
|
|
3933
|
+
claimedSourceAssistantMessageID =
|
|
3934
|
+
claimedGoal.continuationClaim?.sourceAssistantMessageID || ""
|
|
3935
|
+
claimedGoal.budgetWrapupSent = true
|
|
3936
|
+
claimedGoal.stopped = true
|
|
3937
|
+
claimedGoal.stopReason = limitReason
|
|
3938
|
+
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
3939
|
+
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
3940
|
+
await persist()
|
|
3941
|
+
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
3942
|
+
let response
|
|
3943
|
+
try {
|
|
3944
|
+
response = await sessionApi.promptAsync(sessionID, {
|
|
3945
|
+
...continuationContextInput(claimedGoal),
|
|
3946
|
+
parts: [makeContinuationPart(buildContinueMessage(claimedGoal, { budgetWrapup: true }))],
|
|
3947
|
+
})
|
|
3948
|
+
} finally {
|
|
3949
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
3950
|
+
}
|
|
3951
|
+
if (response?.error) {
|
|
3952
|
+
claimedGoal.lastStatus = `${limitReason}; final handoff request failed: ${response.error.name || "unknown error"}.`
|
|
3953
|
+
pushHistory(claimedGoal, "error", claimedGoal.lastStatus)
|
|
3954
|
+
}
|
|
3623
3955
|
} else {
|
|
3624
3956
|
activeGoalAfterMessages.stopped = true
|
|
3625
3957
|
activeGoalAfterMessages.stopReason = limitReason
|
|
@@ -3741,6 +4073,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3741
4073
|
}
|
|
3742
4074
|
|
|
3743
4075
|
const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
|
|
4076
|
+
let cooldownWaited = false
|
|
3744
4077
|
if (
|
|
3745
4078
|
activeGoalAfterMessages.lastContinueAt &&
|
|
3746
4079
|
elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
|
|
@@ -3750,10 +4083,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3750
4083
|
continueController.signal,
|
|
3751
4084
|
)
|
|
3752
4085
|
if (!delayCompleted) return
|
|
4086
|
+
cooldownWaited = true
|
|
3753
4087
|
}
|
|
3754
4088
|
|
|
3755
|
-
const activeGoalBeforePrompt =
|
|
4089
|
+
const activeGoalBeforePrompt = await claimContinuationSource(
|
|
4090
|
+
sessionID,
|
|
4091
|
+
goalID,
|
|
4092
|
+
runID,
|
|
4093
|
+
messages,
|
|
4094
|
+
{ refreshMessages: cooldownWaited },
|
|
4095
|
+
)
|
|
3756
4096
|
if (!activeGoalBeforePrompt) return
|
|
4097
|
+
claimedSourceAssistantMessageID =
|
|
4098
|
+
activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
|
|
3757
4099
|
|
|
3758
4100
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
3759
4101
|
if (budgetWrapup) {
|
|
@@ -3810,22 +4152,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3810
4152
|
}
|
|
3811
4153
|
}
|
|
3812
4154
|
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
4155
|
+
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
4156
|
+
let response
|
|
4157
|
+
try {
|
|
4158
|
+
response = await sessionApi.promptAsync(sessionID, {
|
|
4159
|
+
...continuationContextInput(activeGoalBeforePrompt),
|
|
4160
|
+
parts: [
|
|
4161
|
+
makeContinuationPart(
|
|
4162
|
+
buildContinueMessage(activeGoalBeforePrompt, {
|
|
4163
|
+
budgetWrapup,
|
|
4164
|
+
completionUnverified,
|
|
4165
|
+
blockerUnstated,
|
|
4166
|
+
}),
|
|
4167
|
+
),
|
|
4168
|
+
],
|
|
4169
|
+
})
|
|
4170
|
+
} finally {
|
|
4171
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
4172
|
+
}
|
|
3824
4173
|
|
|
3825
4174
|
if (response.error) {
|
|
3826
4175
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3827
4176
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
3828
|
-
if (
|
|
4177
|
+
if (
|
|
4178
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4179
|
+
claimedSourceAssistantMessageID
|
|
4180
|
+
) {
|
|
4181
|
+
activeGoalAfterPrompt.continuationClaim = null
|
|
3829
4182
|
activeGoalAfterPrompt.promptFailures += 1
|
|
3830
4183
|
activeGoalAfterPrompt.lastStatus = message
|
|
3831
4184
|
pushHistory(activeGoalAfterPrompt, "error", message)
|
|
@@ -3838,7 +4191,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3838
4191
|
await logPluginError(client, message, response.error)
|
|
3839
4192
|
} else {
|
|
3840
4193
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3841
|
-
if (
|
|
4194
|
+
if (
|
|
4195
|
+
activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
|
|
4196
|
+
claimedSourceAssistantMessageID
|
|
4197
|
+
) {
|
|
3842
4198
|
// Decrement rather than reset: an alternating error/success pattern
|
|
3843
4199
|
// should still accumulate toward the circuit-breaker cap over time,
|
|
3844
4200
|
// matching the formatFailures approach for the same reason.
|
|
@@ -3856,6 +4212,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3856
4212
|
} catch (error) {
|
|
3857
4213
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
3858
4214
|
if (activeGoalAfterError) {
|
|
4215
|
+
if (
|
|
4216
|
+
claimedSourceAssistantMessageID &&
|
|
4217
|
+
activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
|
|
4218
|
+
claimedSourceAssistantMessageID
|
|
4219
|
+
) {
|
|
4220
|
+
activeGoalAfterError.continuationClaim = null
|
|
4221
|
+
}
|
|
3859
4222
|
activeGoalAfterError.promptFailures += 1
|
|
3860
4223
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
3861
4224
|
activeGoalAfterError.lastStatus = message
|
|
@@ -3869,6 +4232,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3869
4232
|
}
|
|
3870
4233
|
await logPluginError(client, "Auto-continue failed", error)
|
|
3871
4234
|
} finally {
|
|
4235
|
+
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
3872
4236
|
// Only delete our own entry. If cleanupGoal already removed it (because
|
|
3873
4237
|
// the goal completed) and a new handler has since set a fresh token,
|
|
3874
4238
|
// we must not clobber the new handler's guard.
|