opencode-goal-plugin 0.4.1 → 0.4.7

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 CHANGED
@@ -2,6 +2,76 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.7 — 2026-06-29
6
+
7
+ ### Bug fixes (low-severity cleanups)
8
+
9
+ - **Dead `if (goal.goalId !== previousGoalId)` conditional removed from both resume paths.** `resetGoalBudget` always rotates `goalId` via `randomUUID()`, so the conditional was always `true`. The misleading branch could never be taken, masking the intent (unconditional registry re-key on resume). Both the agent-tool `updateGoal {status: "resumed"}` path and the `/goal resume` command path are now unconditional.
10
+ - **`noToolCallTurns` no longer stales on null-assistant idles.** When `messages()` returns no assistant message (only a user turn), `latestAssistant` is `null` and `latestHasToolCall` is `false`. Previously the counter incremented unconditionally; a user-only idle turn could push the goal toward a no-tool-call pause even though the model hadn't spoken. The reset condition now includes `|| !latestAssistant`, matching the intent of the stall detector.
11
+ - **`noProgressTurns` no longer stales on null-assistant idles.** Same scenario as above: when `latestOutputTokens === null` and there is no assistant message, the counter now resets instead of incrementing, consistent with the gate's purpose of detecting stalled model output.
12
+ - **Updated ledger-durability comment near `pushHistory("completed")`.** The previous comment implied the ledger was the primary recovery mechanism. The corrected comment clarifies that ledger write failures are silent (bare `catch`), and that a present state file always takes precedence over the ledger — making the ledger relevant only when the state file is absent.
13
+ - **`buildAgentToolHandlers` accepts a `persistTerminalState` option.** Terminal state transitions (`status='complete'` and `clearGoal`) now call `persistTerminalState` if provided, falling back to the regular `persist` function. The `GoalPlugin` factory passes its own `persistTerminalState` closure through, so agent-triggered completions and clears get the same durable flush semantics as the event-handler paths.
14
+
15
+ ## 0.4.6 — 2026-06-29
16
+
17
+ ### Bug fixes (counters, compaction, and auditor)
18
+
19
+ - **`noToolCallTurns` is now independent of `noProgressTurns`.** On a turn that qualifies for the noProgress stall gate (low output, no tool call, stalled text), the noToolCall counter no longer also increments. Without this guard, the effective grace window was `min(noProgress, noToolCall)` rather than two independent limits — a configured higher `noProgressTurnsBeforePause` threshold was silently overridden by the lower `noToolCallTurnsBeforePause`.
20
+ - **`formatFailures` is now incremented when the stall gate fires and returns early.** Stall detection previously returned before the format-failure accumulator could run. A model that repeatedly emitted bare `[goal:complete]` with low output triggered the stall gate rather than accumulating toward the `maxPromptFailures` cap; the cap was permanently unreachable because `/goal resume` reset `formatFailures` to zero each time. The counter now increments inside the stall-gate early-return path when `completionUnverified` or `blockerUnstated` is true.
21
+ - **Budget-wrapup state is persisted before the wrapup prompt is sent.** Previously `budgetWrapupSent = true` and `stopped = true` were set in memory but not persisted before `promptAsync`. A crash during the prompt would result in `budgetWrapupSent: false` in the state file and a duplicate wrapup on the next resume cycle. The fix adds `pushHistory("budget-wrapup")` + `persist()` before the prompt call, mirroring the hard-limit path.
22
+ - **`TOOL_PART_TYPES` now covers raw provider part type names.** Some OpenCode adapters forward the provider's original message part shape without normalizing to `"tool"`. Added `"tool_use"`, `"function_call"`, and `"tool-call"` to the set so `messageHasToolCall` (and both stall gates) correctly recognize tool-using turns from non-normalized adapters.
23
+ - **Approved completion that is lost while the auditor is in flight now produces an announcement.** If the goal is cleared or replaced while a completion auditor runs, and the auditor returns `approved: true`, the plugin now announces "completion was approved but the goal was modified while the audit ran — completion not recorded." Previously the approved result was silently discarded with no visible trace.
24
+ - **`buildCompactionContext` is now deterministic.** The function previously called `Date.now()` to compute elapsed seconds, so two calls during the same compaction event produced different strings, busting the prefix cache from that byte position. The elapsed time is now derived from `goal.lastContinueAt` (set during each persist cycle), making the output stable and matching the function's own claim of being "reconstructed deterministically from the plugin's persisted goal record."
25
+
26
+ ## 0.4.5 — 2026-06-29
27
+
28
+ ### Bug fixes (input validation + counter correctness)
29
+
30
+ - **`set_goal` now validates budget arguments and mode.** Previously `set_goal({maxTurns: 0})` silently used the global default; a typo in `mode` silently became `"normal"`. Both now return explicit errors, matching the `/goal` command's validation behavior.
31
+ - **`update_goal` cannot combine an objective update with `status='complete'` in the same call.** The completion would be archived under a condition that was never executed, falsifying the audit trail. The tool now requires two separate calls: first update the objective, then mark complete after the revised work is done.
32
+ - **`update_goal {status: 'resumed'}` on a running goal returns an error.** The slash-command path rejected this; the agent tool path silently reset all budget counters — turnCount, totalTokens, startedAt, etc. — on a goal that never stopped, enabling indefinite budget circumvention. The agent tool now rejects the call when the goal is not stopped.
33
+ - **`/goal edit` and `update_goal` objective updates now reset `formatFailures` to 0.** The edit paths already reset `noProgressTurns` and `noToolCallTurns` but omitted `formatFailures`. A goal with accumulated format-failure violations had less tolerance than a freshly-resumed goal after an objective change.
34
+ - **`/goal <condition>` replace command now clears `sessionOrdered`.** The agent `setGoal` path called `sessionOrdered.delete()` on replacement, but the slash-command path did not. A user replacing a sisyphus sequence with a standalone goal would get unexpected auto-promotion of the sequence's remaining goals after the replacement completed.
35
+ - **`set_goal` and `update_goal` tool result strings now escape XML metacharacters.** The `goal.condition` is stored raw (for use by `buildGoalBlock`/`buildContinueMessage`), but the tool result returned to the model now calls `escapeGoalText` to prevent XML metacharacters from breaking tool-result boundaries in XML-serialized formats.
36
+ - **`promptFailures` decrements by 1 on a successful prompt instead of resetting to 0.** This mirrors the `formatFailures` fix: an alternating error/success pattern previously bypassed the circuit-breaker cap indefinitely. Decrementing allows gradual recovery while still accumulating toward the cap over time.
37
+
38
+ ## 0.4.4 — 2026-06-29
39
+
40
+ ### Bug fixes (state machine + injection prevention)
41
+
42
+ - **`escapeGoalText` now neutralizes role-like tag openings.** The previous `STRUCTURAL_TAGS` set only covered plugin-defined tags. Tags like `<system>`, `<assistant>`, `<human>`, `<anthropic>`, `<claude>`, `<context>`, `<instructions>`, and `<prompt>` could survive unescaped in compacted system messages, creating second-order injection opportunities where model output captured by `recordCheckpoint` re-appeared as an elevated-privilege block after compaction.
43
+ - **`update_goal` objective update no longer un-stops a stopped goal.** Calling `update_goal({objective: "…"})` previously cleared `goal.stopped` and `goal.stopReason`, silently resurrecting a goal that was audit-rejected, user-paused, or blocked for any reason. Objective updates now preserve the stopped state; only an explicit `status: "resumed"` call resets it.
44
+ - **`/goal clear` and agent `clearGoal` now delete all backgrounded goals.** Previously only the focused goal was removed from the session registry (`cleanupGoal` → `removeSessionGoal`). Background goals added via `/goal add` remained alive and would promote themselves to focused on restart. Both clear paths now call `sessionGoals.delete(sessionID)` first, wiping the entire per-session goal map.
45
+ - **`formatFailures` decrements by 1 on a clean turn instead of resetting to 0.** A reset-to-zero on every non-violation turn allowed an alternating bad/good/bad pattern to bypass the consecutive-failure cap indefinitely. Decrementing by 1 means repeated violations accumulate toward the cap even when interspersed with good turns.
46
+ - **`update_goal {status: "blocked"}` requires a non-empty `blocker` argument.** The event-handler path already rejects a `[goal:blocked]` marker with no concrete blocker, but the agent tool path accepted an empty `blocker` (recording an empty `blockedReason`). The agent tool now returns an error when `blocker` is missing or whitespace-only, consistent with the auto-continue guard.
47
+
48
+ ## 0.4.3 — 2026-06-29
49
+
50
+ ### Bug fixes (concurrency + persistence)
51
+
52
+ - **`activeContinues` Set → Map with per-handler UUID token.** `cleanupGoal` removes the session from the Map (allowing new handlers to start), but the idle handler's `finally` block only deletes if its token still matches — preventing it from clobbering a new handler's guard. With a plain `Set`, the old `finally` unconditionally deleted the new handler's entry, creating a race window where two handlers could run concurrently for the same session.
53
+ - **Liveness re-check after `announceAudit`.** `announceAudit` is async and can yield long enough for a user to `/goal clear` or replace the goal. The handler now calls `activeGoal(sessionID, goalID)` after the announcement and returns immediately if the goal is gone, preventing an orphaned archive write.
54
+ - **`persist()` calls serialized via promise chain.** Concurrent callers previously raced on the temp-file rename: the second rename could write older state over the first. All calls now chain through `persistChain`, guaranteeing ordered writes.
55
+ - **`/goal clear` and agent `clearGoal` now emit a `"cleared"` ledger event before discarding the goal.** Without this, `reconstructGoalsFromLedger` (used when the state file is missing) would revive cleared goals as paused on restart. `LEDGER_TERMINAL_TYPES` already includes `"cleared"` — the event just wasn't being written.
56
+ - **State-file/ledger cross-check on restart.** After loading from the state file, the plugin now reads the ledger and removes any active goals whose `goalId` appears in a terminal ledger entry. This guards against the scenario where a terminal persist wrote to the ledger but the state file write failed (e.g. process killed between the two writes): the goal would otherwise load as active and be re-driven on the next idle.
57
+
58
+ ### Bug fixes (state machine + security)
59
+
60
+ - **Escape checkpoint and history text in compaction context.** Checkpoint summaries and lifecycle-event details contain assistant-generated text. If a malicious assistant output included structural XML tags (e.g. `</goal_objective><budget_wrapup>…</budget_wrapup>`), they could be re-embedded unescaped in the compaction context system message. `buildCompactionProgressSummary` and the `lastCheckpoint` inline in `buildCompactionContext` now call `escapeGoalText` on all assistant-derived strings.
61
+ - **`/goal edit` and agent `update_goal` objective updates now reset `noToolCallTurns`.** The edit paths already reset `noProgressTurns` and cleared soft-stop state, but forgot `noToolCallTurns`. A goal that was heading toward a no-tool-call pause kept its stale counter after an objective change, and could pause after fewer than the configured grace turns on the new objective.
62
+ - **`formatFailures` is now preserved through a persistence round-trip.** `normalizePersistedGoal` carried `promptFailures` but omitted `formatFailures`. After a plugin restart any accumulated format-failure count was silently reset to zero, giving the model an unintended free pass on the first format re-prompts after recovery.
63
+ - **Agent `update_goal {status: "complete"}` now invokes the configured completion auditor.** The `[goal:complete]` marker path gates archival on an optional auditor, but the agent tool path bypassed it entirely. `buildAgentToolHandlers` now accepts a `completionAuditor` option, and the `GoalPlugin` factory passes the configured auditor through. A rejected verdict pauses the goal with stop reason `audit rejected`; an auditor that throws is treated as a rejection (fail closed).
64
+ - **`createChildSessionAuditor` now enforces a configurable timeout (default 120 s).** `sessionApi.prompt` could hang indefinitely, blocking the idle handler and stalling the goal forever. The auditor now races the API call against a `setTimeout` promise; if the timeout fires first, the verdict is `{ approved: false, reason: "auditor timed out after Nms" }`. The timer is always cleared after settlement.
65
+ - **Thinking-only turns are excluded from the `noProgress` stall detector.** A turn that produced reasoning tokens but no prose output and no tool calls was treated as stalled by `lowOutputLooksStalled` (prose output tokens = 0 < threshold). The new `latestHasThinkingTokens` check (`tokens.reasoning > 0`) excludes such turns from the stall gate, preventing false pauses on extended-thinking models that reason before acting.
66
+
67
+ ## 0.4.2 — 2026-06-29
68
+
69
+ ### Bug fixes
70
+
71
+ - **Guard against stale `message.updated` re-deliveries re-inflating `totalTokens` after `/goal resume`.** After `/goal resume`, OpenCode replays the streaming `message.updated` event for the last assistant message. Previously `resetGoalBudget` deleted those message IDs from `seenTokens`, so the replayed event looked new and added its tokens to the freshly-zeroed counter — incorrectly inflating the resumed goal's token total from the first turn. Fix: `resetGoalBudget` no longer deletes IDs from `seenTokens`. The `message.updated` handler now skips any message whose ID is already in `seenTokens` but is absent from the current `goal.messageIDs` (i.e. belongs to a prior budget epoch), so stale re-deliveries are silently ignored.
72
+ - **Guard against stale `message.updated` re-deliveries inflating a replacement goal's `totalTokens`.** When a goal is replaced via `/goal <new>`, the old goal's `cleanupGoal` path previously deleted its message IDs from `seenTokens`. If OpenCode then re-delivered a streaming event for one of those old IDs (e.g. a buffered duplicate), the new goal object had no record of it, so the guard could not fire and the event inflated the new goal's counter. Fix: `cleanupGoal` also leaves `seenTokens` entries in place. Entries accumulate across the process lifetime (O(turns × messages_per_turn)) and are cleared in bulk by `clearRuntimeState` on teardown.
73
+ - **Reset `totalTokens` to zero after session compaction.** `totalTokens` is tracked with `Math.max` semantics (peak context size), so it never decreases on its own. A goal that crossed the 80 % budget-wrapup threshold before compaction would permanently remain above it even after the context shrank to a fraction of its prior size. Fix: the `experimental.session.compacting` hook now resets `totalTokens = 0` and rotates `messageIDs` into `priorMessageIDs` after injecting the compaction context, then calls `persist()`. Post-compaction turns re-establish the token baseline from scratch.
74
+
5
75
  ## 0.4.1 — 2026-06-29
6
76
 
7
77
  ### Bug fixes
package/README.md CHANGED
@@ -290,6 +290,19 @@ By default a `[goal:complete]` is accepted on the assistant's word. You can requ
290
290
 
291
291
  On **approval** the goal is archived as achieved. On **rejection** the goal is *not* archived — it is paused with stop reason `audit rejected` and the reason in its status, so you can address the gap and `/goal resume`. The built-in child-session auditor fails *open* (auto-approves) if the session API is unavailable, while a custom auditor that throws is treated as a rejection (fail closed). The audit is off unless one of these options is set.
292
292
 
293
+ Pass `auditorOptions` to tune the built-in auditor:
294
+
295
+ ```js
296
+ GoalPlugin({
297
+ completionAudit: true,
298
+ auditorOptions: {
299
+ timeoutMs: 60_000, // default 120 000 ms; set lower for faster CI feedback
300
+ },
301
+ })
302
+ ```
303
+
304
+ `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. If the session doesn't reply within the timeout the auditor auto-approves (fail open) so the goal can still be archived. `auditorOptions` is ignored when a custom `auditor` function is supplied.
305
+
293
306
  ## Prompt safety
294
307
 
295
308
  The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.4.1",
3
+ "version": "0.4.7",
4
4
  "description": "Session-scoped /goal workflow for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -56,7 +56,13 @@ const MAX_ARCHIVED_PER_SESSION = 10
56
56
  const lastGoalResults = new Map()
57
57
  const seenTokens = new Map()
58
58
  const seenOutputTokens = new Map()
59
- const activeContinues = new Set()
59
+ // Map<sessionID, token> rather than Set so the idle handler's finally block can
60
+ // detect whether its entry has been superseded by a new handler: if cleanupGoal
61
+ // deletes the sessionID (allowing a new handler to start and set a fresh token)
62
+ // before the old handler's finally fires, the old finally skips the delete
63
+ // because the token no longer matches. With a plain Set, the old finally would
64
+ // unconditionally delete the new handler's guard, exposing a race window.
65
+ const activeContinues = new Map()
60
66
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
61
67
  const PAUSE_COMMANDS = new Set(["pause"])
62
68
  const GOAL_FLAG_SPECS = {
@@ -111,7 +117,9 @@ const GOAL_FLAG_SPECS = {
111
117
  // shapes count as tool-using turns too). A continuation turn with none of these
112
118
  // is "talk only" — a signal of a self-chat loop the auto-continue should not
113
119
  // keep feeding.
114
- const TOOL_PART_TYPES = new Set(["tool", "tool-invocation", "subtask"])
120
+ // Covers both normalized OpenCode types and raw provider-specific part types
121
+ // (some adapters forward the provider's original shape without normalizing).
122
+ const TOOL_PART_TYPES = new Set(["tool", "tool-invocation", "subtask", "tool_use", "function_call", "tool-call"])
115
123
 
116
124
  function messageHasToolCall(message) {
117
125
  const parts = Array.isArray(message?.parts) ? message.parts : []
@@ -442,10 +450,13 @@ function promoteNextOrderedGoal(sessionID) {
442
450
  function cleanupGoal(sessionID) {
443
451
  const goal = goalStates.get(sessionID)
444
452
  if (goal) {
445
- for (const messageID of goal.messageIDs) {
446
- seenTokens.delete(messageID)
447
- seenOutputTokens.delete(messageID)
448
- }
453
+ // seenTokens entries for this goal's message IDs are intentionally NOT deleted
454
+ // here. resetGoalBudget also leaves them in place. The message.updated handler
455
+ // uses the presence of an ID in seenTokens combined with its absence from the
456
+ // current goal.messageIDs to detect and skip stale re-deliveries — deleting
457
+ // entries here would break that guard for post-replacement stale events.
458
+ // Entries are cleared in bulk by clearRuntimeState on plugin teardown; the
459
+ // per-process accumulation is small (O(turns × messages_per_turn)).
449
460
  removeSessionGoal(sessionID, goal.goalId)
450
461
  }
451
462
  goalStates.delete(sessionID)
@@ -505,10 +516,11 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
505
516
  }
506
517
 
507
518
  function resetGoalBudget(goal) {
508
- for (const messageID of goal.messageIDs) {
509
- seenTokens.delete(messageID)
510
- seenOutputTokens.delete(messageID)
511
- }
519
+ // Do NOT delete old message IDs from seenTokens here. The message.updated
520
+ // handler guards against stale re-deliveries by checking whether the message ID
521
+ // is in seenTokens but NOT in the current goal.messageIDs — keeping the entries
522
+ // alive is what makes that check reliable. cleanupGoal removes them when the
523
+ // goal is fully discarded, so seenTokens entries are bounded to active goals.
512
524
  goal.goalId = randomUUID()
513
525
  goal.startedAt = Date.now()
514
526
  goal.turnCount = 0
@@ -765,6 +777,7 @@ function normalizePersistedGoal(rawGoal) {
765
777
  stopped: rawGoal.stopped === true,
766
778
  stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
767
779
  promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
780
+ formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
768
781
  messageIDs: Array.isArray(rawGoal.messageIDs)
769
782
  ? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID)
770
783
  : [],
@@ -925,6 +938,38 @@ async function applyParsedStateFile(raw, client) {
925
938
  return "loaded"
926
939
  }
927
940
 
941
+ // After applyParsedStateFile loads goals into goalStates, check the ledger for
942
+ // terminal events. If a goal has a "completed" or "cleared" entry in the ledger
943
+ // but still appears active in the state file (because the state write failed
944
+ // after the terminal ledger write), remove it so it is not re-driven.
945
+ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
946
+ const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath)
947
+ if (!entries.length) return
948
+
949
+ const terminalGoalIds = new Set()
950
+ for (const entry of entries) {
951
+ if (LEDGER_TERMINAL_TYPES.has(entry.type) && typeof entry.goalId === "string" && entry.goalId) {
952
+ terminalGoalIds.add(entry.goalId)
953
+ }
954
+ }
955
+ if (!terminalGoalIds.size) return
956
+
957
+ let removed = 0
958
+ for (const [sessionID, goal] of goalStates.entries()) {
959
+ if (terminalGoalIds.has(goal.goalId)) {
960
+ removeSessionGoal(sessionID, goal.goalId)
961
+ goalStates.delete(sessionID)
962
+ removed++
963
+ }
964
+ }
965
+ if (removed > 0) {
966
+ await logPluginError(
967
+ client,
968
+ `Ledger cross-check: removed ${removed} goal(s) whose terminal state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`,
969
+ )
970
+ }
971
+ }
972
+
928
973
  async function loadPersistedState(persistenceOptions, client) {
929
974
  if (!persistenceOptions.persistState) return "disabled"
930
975
 
@@ -955,7 +1000,15 @@ async function loadPersistedState(persistenceOptions, client) {
955
1000
  continue
956
1001
  }
957
1002
 
958
- if (status === "loaded") return primary ? "loaded" : "migrated"
1003
+ if (status === "loaded") {
1004
+ // Cross-check: the ledger is written before the state file for terminal
1005
+ // events (completed, cleared). If the terminal persist succeeded in the
1006
+ // ledger but the state file write failed (e.g. process killed between the
1007
+ // two writes), the reloaded state may still have the goal as active. Remove
1008
+ // any loaded active goals whose goalId has a terminal ledger entry.
1009
+ await reconcileLoadedStateWithLedger(persistenceOptions, client)
1010
+ return primary ? "loaded" : "migrated"
1011
+ }
959
1012
  // status === "invalid": preserve a present-but-corrupt primary; for a
960
1013
  // fallback, keep trying the next candidate.
961
1014
  if (primary) return "invalid"
@@ -1179,6 +1232,17 @@ const STRUCTURAL_TAGS = [
1179
1232
  "next_step",
1180
1233
  "completion_audit",
1181
1234
  "evidence_required",
1235
+ // Role-like names that model providers treat as elevated context (second-order
1236
+ // injection: a goal could guide the model to emit these in output captured by
1237
+ // recordCheckpoint, then re-injected via compaction or buildGoalBlock).
1238
+ "system",
1239
+ "instructions",
1240
+ "human",
1241
+ "assistant",
1242
+ "anthropic",
1243
+ "claude",
1244
+ "context",
1245
+ "prompt",
1182
1246
  ]
1183
1247
  const STRUCTURAL_OPEN_TAG_RE = new RegExp(`<(${STRUCTURAL_TAGS.join("|")})\\b`, "gi")
1184
1248
 
@@ -1321,14 +1385,17 @@ function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents =
1321
1385
  if (checkpoints.length) {
1322
1386
  lines.push("Recent checkpoints (oldest first):")
1323
1387
  for (const checkpoint of checkpoints) {
1324
- lines.push(`- ${summarizeText(checkpoint.summary, 200)}`)
1388
+ // Escape: checkpoint summaries contain assistant-generated text; an
1389
+ // adversarial model output could inject structural tags into this string,
1390
+ // which would be re-embedded in the compaction context system message.
1391
+ lines.push(`- ${escapeGoalText(summarizeText(checkpoint.summary, 200))}`)
1325
1392
  }
1326
1393
  }
1327
1394
  const events = Array.isArray(goal.history) ? goal.history.slice(-maxEvents) : []
1328
1395
  if (events.length) {
1329
1396
  lines.push("Recent lifecycle events (oldest first):")
1330
1397
  for (const event of events) {
1331
- lines.push(`- ${event.type}: ${summarizeText(event.detail, 160)}`)
1398
+ lines.push(`- ${event.type}: ${escapeGoalText(summarizeText(event.detail, 160))}`)
1332
1399
  }
1333
1400
  }
1334
1401
  return lines
@@ -1339,14 +1406,19 @@ function buildCompactionContext(goal) {
1339
1406
  // this, a compaction can drop the goal objective and budget state from the
1340
1407
  // working context, so the assistant loses the thread mid-run even though the
1341
1408
  // plugin still re-injects via system.transform afterward.
1342
- const elapsedSeconds = Math.round((Date.now() - goal.startedAt) / 1000)
1409
+ // Use goal.lastContinueAt (set on each persist cycle) rather than Date.now()
1410
+ // so buildCompactionContext is deterministic. If OpenCode calls the compacting
1411
+ // hook more than once, each invocation produces the same elapsedSeconds and
1412
+ // therefore the same string — preserving the prefix cache from this point on.
1413
+ const snapshotAt = goal.lastContinueAt || goal.startedAt || 0
1414
+ const elapsedSeconds = Math.round((snapshotAt - goal.startedAt) / 1000)
1343
1415
  return [
1344
1416
  "An OpenCode goal is active for this session. Preserve it across compaction.",
1345
1417
  "The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.",
1346
1418
  buildGoalBlock(goal),
1347
1419
  `Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
1348
1420
  `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
1349
- goal.lastCheckpoint ? `Latest checkpoint: ${goal.lastCheckpoint.summary}` : null,
1421
+ goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(goal.lastCheckpoint.summary)}` : null,
1350
1422
  ...buildCompactionProgressSummary(goal),
1351
1423
  "After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] (preceded by a [goal:evidence] line) only when fully satisfied, or [goal:blocked] (preceded by a concrete blocker) only if user input is required.",
1352
1424
  ]
@@ -1601,7 +1673,11 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
1601
1673
  // result. Goal creation/replacement routes through the multi-goal registry
1602
1674
  // (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
1603
1675
  // path, so tool-created goals persist and are driven by the idle handler.
1604
- function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1676
+ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState = null, completionAuditor = null }) {
1677
+ // Use persistTerminalState (which logs on failure) for terminal operations when
1678
+ // available; fall back to plain persist for callers that don't wire it up (e.g.
1679
+ // tests using buildAgentToolHandlers directly).
1680
+ const persistFinal = persistTerminalState || persist
1605
1681
  async function getGoal(sessionID) {
1606
1682
  const goal = goalStates.get(sessionID)
1607
1683
  if (goal) return formatStatus(goal)
@@ -1638,6 +1714,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1638
1714
  const objective = typeof args.objective === "string" ? args.objective.trim() : ""
1639
1715
  if (!objective) return "No objective provided. Pass a non-empty `objective`."
1640
1716
 
1717
+ // Validate budget args before normalizing: normalizeOptions silently substitutes
1718
+ // defaults for non-positive values, giving no feedback to the caller.
1719
+ if (Number.isFinite(args.maxTurns) && args.maxTurns <= 0)
1720
+ return `Invalid maxTurns: ${args.maxTurns} — must be a positive integer.`
1721
+ if (Number.isFinite(args.maxTokens) && args.maxTokens <= 0)
1722
+ return `Invalid maxTokens: ${args.maxTokens} — must be a positive integer.`
1723
+ if (Number.isFinite(args.maxDurationMs) && args.maxDurationMs <= 0)
1724
+ return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
1725
+ if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
1726
+ return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
1727
+
1641
1728
  const options = normalizeOptions({
1642
1729
  ...defaultGoalOptions,
1643
1730
  ...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
@@ -1664,25 +1751,48 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1664
1751
  registerSessionGoal(goal)
1665
1752
  focusGoal(sessionID, goal)
1666
1753
  await persist()
1667
- return `New active goal: ${goal.condition}`
1754
+ // Escape in the tool result only: goal.condition is stored raw so callers
1755
+ // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
1756
+ // themselves. Escaping here prevents XML metacharacters in user-supplied
1757
+ // objectives from breaking tool-result boundaries in XML-serialized formats.
1758
+ return `New active goal: ${escapeGoalText(goal.condition)}`
1668
1759
  }
1669
1760
 
1670
1761
  async function updateGoal(sessionID, args = {}) {
1671
1762
  const goal = goalStates.get(sessionID)
1672
1763
  if (!goal) return "No active goal to update. Use set_goal first."
1673
1764
 
1765
+ // Reject the combination of an objective update with status='complete': the
1766
+ // completion would be archived under a condition that was never executed,
1767
+ // falsifying the audit trail. Require two separate calls.
1768
+ if (
1769
+ typeof args.objective === "string" &&
1770
+ args.objective.trim() &&
1771
+ String(args.status || "").trim().toLowerCase() === "complete"
1772
+ ) {
1773
+ return (
1774
+ "Cannot combine an objective update with status='complete'. " +
1775
+ "Use two separate calls: first update the objective (which revises the goal), " +
1776
+ "then mark it complete after completing the revised work."
1777
+ )
1778
+ }
1779
+
1674
1780
  const messages = []
1675
1781
 
1676
1782
  if (typeof args.objective === "string" && args.objective.trim()) {
1677
1783
  goal.condition = args.objective.trim()
1678
- goal.stopped = false
1679
- goal.stopReason = ""
1784
+ // Deliberately NOT clearing goal.stopped or goal.stopReason: updating the
1785
+ // objective does not un-stop a goal. Use status='resumed' to explicitly
1786
+ // restart a stopped goal; silently un-stopping would resurrect audit-rejected
1787
+ // or user-paused goals without the user's knowledge.
1680
1788
  goal.blockedReason = ""
1681
1789
  goal.budgetWrapupSent = false
1682
1790
  goal.noProgressTurns = 0
1791
+ goal.noToolCallTurns = 0
1792
+ goal.formatFailures = 0
1683
1793
  goal.lastStatus = "Goal objective updated."
1684
1794
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
1685
- messages.push(`Objective updated: ${goal.condition}`)
1795
+ messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
1686
1796
  }
1687
1797
 
1688
1798
  if (args.status !== undefined) {
@@ -1692,6 +1802,27 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1692
1802
  }
1693
1803
  if (status === "complete") {
1694
1804
  const evidence = typeof args.evidence === "string" ? args.evidence.trim() : ""
1805
+ // If a completion auditor is configured, run it before archiving so the
1806
+ // agent tool path has the same integrity gate as the [goal:complete] marker
1807
+ // path. Without this, an autonomous agent could bypass the auditor by
1808
+ // calling update_goal({status:"complete"}) instead of using the marker.
1809
+ if (completionAuditor) {
1810
+ let verdict
1811
+ try {
1812
+ verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
1813
+ } catch (error) {
1814
+ verdict = { approved: false, reason: "auditor error" }
1815
+ }
1816
+ if (!verdict || verdict.approved !== true) {
1817
+ const reason = (verdict && verdict.reason) || "completion not substantiated"
1818
+ goal.stopped = true
1819
+ goal.stopReason = "audit rejected"
1820
+ goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /goal resume.`
1821
+ pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
1822
+ await persist()
1823
+ return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /goal resume after addressing the issue.`
1824
+ }
1825
+ }
1695
1826
  goal.lastStatus = "Goal completed."
1696
1827
  pushHistory(
1697
1828
  goal,
@@ -1702,15 +1833,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1702
1833
  cleanupGoal(sessionID)
1703
1834
  // Advance an ordered (sisyphus) sequence just like the marker path does.
1704
1835
  if (sessionOrdered.has(sessionID)) promoteNextOrderedGoal(sessionID)
1705
- await persist()
1836
+ await persistFinal("completion")
1706
1837
  return "Goal marked complete and archived."
1707
1838
  }
1708
1839
  if (status === "blocked") {
1709
- goal.blockedReason = typeof args.blocker === "string" ? args.blocker.trim() : ""
1840
+ const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
1841
+ if (!blockerText)
1842
+ return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
1843
+ goal.blockedReason = blockerText
1710
1844
  goal.stopped = true
1711
1845
  goal.stopReason = "blocked"
1712
1846
  goal.lastStatus = "Assistant reported blocked."
1713
- pushHistory(goal, "blocked", goal.blockedReason || "Marked blocked via agent tool.")
1847
+ pushHistory(goal, "blocked", goal.blockedReason)
1714
1848
  messages.push("Goal marked blocked.")
1715
1849
  } else if (status === "paused") {
1716
1850
  goal.stopped = true
@@ -1719,15 +1853,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1719
1853
  pushHistory(goal, "paused", "Paused via agent tool.")
1720
1854
  messages.push("Goal paused.")
1721
1855
  } else if (status === "resumed") {
1856
+ if (!goal.stopped)
1857
+ return "Goal is already running. Pause or stop it first if you want to reset the budget window."
1722
1858
  const previousGoalId = goal.goalId
1723
1859
  resetGoalBudget(goal)
1724
- // resetGoalBudget rotates goalId; re-key the registry so the goal stays
1725
- // findable by its new id (the focused pointer holds the same object).
1726
- if (goal.goalId !== previousGoalId) {
1727
- removeSessionGoal(sessionID, previousGoalId)
1728
- registerSessionGoal(goal)
1729
- focusGoal(sessionID, goal)
1730
- }
1860
+ // resetGoalBudget always rotates goalId via randomUUID; unconditionally
1861
+ // re-key the registry so the goal stays findable by its new id.
1862
+ removeSessionGoal(sessionID, previousGoalId)
1863
+ registerSessionGoal(goal)
1864
+ focusGoal(sessionID, goal)
1731
1865
  goal.stopped = false
1732
1866
  goal.stopReason = ""
1733
1867
  goal.blockedReason = ""
@@ -1745,11 +1879,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
1745
1879
  }
1746
1880
 
1747
1881
  async function clearGoal(sessionID) {
1748
- // Mirror `/goal clear`: drop the ordered flag and the focused goal + result.
1882
+ // Mirror `/goal clear`: drop the ordered flag, ALL backgrounded goals, and the
1883
+ // focused goal + result. Without sessionGoals.delete, background goals added via
1884
+ // `/goal add` survive clear and resurrect as the focused goal on restart.
1885
+ // Record the clear in the ledger before cleanupGoal removes the goal object.
1886
+ const goalBeforeClear = goalStates.get(sessionID)
1887
+ if (goalBeforeClear) pushHistory(goalBeforeClear, "cleared", "Cleared via agent tool.")
1749
1888
  sessionOrdered.delete(sessionID)
1889
+ sessionGoals.delete(sessionID)
1750
1890
  cleanupGoal(sessionID)
1751
1891
  lastGoalResults.delete(sessionID)
1752
- await persist()
1892
+ await persistFinal("clear")
1753
1893
  return "Goal cleared."
1754
1894
  }
1755
1895
 
@@ -1925,9 +2065,9 @@ function extractAuditVerdictText(response) {
1925
2065
  // so a missing/broken auditor pipeline never blocks legitimate completions.
1926
2066
  // NOTE: the exact child-session SDK shape should be confirmed against a live
1927
2067
  // OpenCode; the orchestration around it is what the tests cover.
1928
- function createChildSessionAuditor(client, { agent = "build" } = {}) {
2068
+ function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_000 } = {}) {
1929
2069
  return async ({ goal, sessionID, latestText }) => {
1930
- try {
2070
+ const run = async () => {
1931
2071
  const sessionApi = client?.session
1932
2072
  if (!sessionApi?.create || !sessionApi?.prompt) {
1933
2073
  return { approved: true, reason: "child-session API unavailable; auto-approved" }
@@ -1948,8 +2088,23 @@ function createChildSessionAuditor(client, { agent = "build" } = {}) {
1948
2088
  verdictText = getText(findLatestAssistantMessage(messages?.data)?.parts)
1949
2089
  }
1950
2090
  return parseAuditVerdict(verdictText)
2091
+ }
2092
+
2093
+ let timerID
2094
+ const timeout = new Promise((resolve) => {
2095
+ timerID = setTimeout(
2096
+ () => resolve({ approved: false, reason: `auditor timed out after ${timeoutMs}ms` }),
2097
+ timeoutMs,
2098
+ )
2099
+ })
2100
+
2101
+ try {
2102
+ const result = await Promise.race([run(), timeout])
2103
+ return result
1951
2104
  } catch (error) {
1952
2105
  return { approved: true, reason: `auditor error (auto-approved): ${error?.message || error}` }
2106
+ } finally {
2107
+ clearTimeout(timerID)
1953
2108
  }
1954
2109
  }
1955
2110
  }
@@ -1961,7 +2116,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
1961
2116
  cwd: pluginOptions.cwd,
1962
2117
  })
1963
2118
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
1964
- const persist = async () => persistState(persistenceOptions, client)
2119
+ // Serialize all persist() calls through a promise chain so concurrent callers
2120
+ // never race on the temp-file rename. persistState returns a boolean and never
2121
+ // rejects, so the chain cannot stall on a thrown error.
2122
+ let persistChain = Promise.resolve(true)
2123
+ const persist = () => {
2124
+ persistChain = persistChain.then(() => persistState(persistenceOptions, client))
2125
+ return persistChain
2126
+ }
1965
2127
 
1966
2128
  // Fail-closed (item 2.5): when persisting a terminal state (complete/blocked)
1967
2129
  // fails, surface it loudly. The terminal event is already in the append-only
@@ -2023,7 +2185,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2023
2185
  await persist()
2024
2186
  }
2025
2187
 
2026
- const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist })
2188
+ const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor })
2027
2189
 
2028
2190
  const hooks = {
2029
2191
  "command.execute.before": async (input, output) => {
@@ -2076,7 +2238,16 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2076
2238
  }
2077
2239
 
2078
2240
  if (CLEAR_COMMANDS.has(args)) {
2241
+ // Record the clear in the ledger before cleanupGoal removes the goal
2242
+ // object, so reconstructFromLedger can identify cleared goals and skip
2243
+ // them rather than reconstructing them after a missing state file.
2244
+ // sessionGoals.delete clears ALL backgrounded goals so they do not
2245
+ // resurrect as the focused goal on restart (cleanupGoal only removes the
2246
+ // focused one; background goals from `/goal add` would survive otherwise).
2247
+ const goalBeforeClear = goalStates.get(sessionID)
2248
+ if (goalBeforeClear) pushHistory(goalBeforeClear, "cleared", "User cleared the goal.")
2079
2249
  sessionOrdered.delete(sessionID)
2250
+ sessionGoals.delete(sessionID)
2080
2251
  cleanupGoal(sessionID)
2081
2252
  lastGoalResults.delete(sessionID)
2082
2253
  await persist()
@@ -2112,14 +2283,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2112
2283
 
2113
2284
  const previousGoalId = goal.goalId
2114
2285
  resetGoalBudget(goal)
2115
- // resetGoalBudget rotates goalId; re-key the multi-goal registry to the
2116
- // new id so a later clear/replace removes the goal instead of leaking a
2117
- // stale entry (the focused pointer holds the same object reference).
2118
- if (goal.goalId !== previousGoalId) {
2119
- removeSessionGoal(sessionID, previousGoalId)
2120
- registerSessionGoal(goal)
2121
- focusGoal(sessionID, goal)
2122
- }
2286
+ // resetGoalBudget always rotates goalId via randomUUID; unconditionally
2287
+ // re-key the registry so a later clear/replace removes the right entry.
2288
+ removeSessionGoal(sessionID, previousGoalId)
2289
+ registerSessionGoal(goal)
2290
+ focusGoal(sessionID, goal)
2123
2291
  goal.stopped = false
2124
2292
  goal.stopReason = ""
2125
2293
  goal.blockedReason = ""
@@ -2156,6 +2324,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2156
2324
  goal.blockedReason = ""
2157
2325
  goal.budgetWrapupSent = false
2158
2326
  goal.noProgressTurns = 0
2327
+ goal.noToolCallTurns = 0
2328
+ goal.formatFailures = 0
2159
2329
  goal.lastStatus = "Goal objective updated."
2160
2330
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
2161
2331
  await persist()
@@ -2358,7 +2528,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2358
2528
 
2359
2529
  // Replace the focused goal (cleanupGoal discards it); backgrounded goals
2360
2530
  // for this session are preserved. Use `/goal add` to keep the current
2361
- // goal and add another.
2531
+ // goal and add another. Clear any ordered-sequence flag so the new
2532
+ // standalone goal does not trigger sisyphus auto-promotion of old sequence
2533
+ // goals that may still be in the registry (matches the agent setGoal path).
2534
+ sessionOrdered.delete(sessionID)
2362
2535
  cleanupGoal(sessionID)
2363
2536
  lastGoalResults.delete(sessionID)
2364
2537
  registerSessionGoal(goal)
@@ -2398,6 +2571,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2398
2571
  const currentMessageID = messageID(message)
2399
2572
  if (!currentMessageID) return
2400
2573
 
2574
+ // Skip stale re-deliveries from a prior budget window or a replaced goal.
2575
+ // resetGoalBudget and cleanupGoal both leave seenTokens entries in place
2576
+ // so this guard can fire: if an ID is already recorded in seenTokens but
2577
+ // is absent from the current goal.messageIDs, it belongs to a previous
2578
+ // budget epoch or a different goal that was replaced, and the event must
2579
+ // not re-inflate totalTokens.
2580
+ if (seenTokens.has(currentMessageID) && !goal.messageIDs.has(currentMessageID)) return
2581
+
2401
2582
  let changed = false
2402
2583
  const currentOutputTokens = outputTokensForMessage(message)
2403
2584
  const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
@@ -2438,7 +2619,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2438
2619
  if (!goal || goal.stopped || activeContinues.has(sessionID)) return
2439
2620
  const goalID = goal.goalId
2440
2621
 
2441
- activeContinues.add(sessionID)
2622
+ const continueToken = randomUUID()
2623
+ activeContinues.set(sessionID, continueToken)
2442
2624
  try {
2443
2625
  const messages = await client.session.messages({
2444
2626
  path: { id: sessionID },
@@ -2494,6 +2676,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2494
2676
  sessionID,
2495
2677
  `Auditing goal completion: verifying "${summarizeText(activeGoalAfterMessages.condition, 120)}" is satisfied before archiving.`,
2496
2678
  )
2679
+ // Re-check liveness: announceAudit is async and can yield long enough
2680
+ // for the user to /goal clear or replace the goal. If it's gone,
2681
+ // bail out without archiving — archiving a cleared goal would resurrect
2682
+ // it in memory and potentially in the persisted state.
2683
+ if (!activeGoal(sessionID, goalID)) return
2497
2684
  // Optional independent auditor (item 2.2): an approved verdict
2498
2685
  // archives; a rejected verdict restores (pauses) the goal instead.
2499
2686
  if (completionAuditor) {
@@ -2505,7 +2692,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2505
2692
  verdict = { approved: false, reason: "auditor error" }
2506
2693
  }
2507
2694
  const auditedGoal = activeGoal(sessionID, goalID)
2508
- if (!auditedGoal) return
2695
+ if (!auditedGoal) {
2696
+ // The goal was cleared or replaced while the auditor was running.
2697
+ // If the verdict was approved, surface the loss so the user knows
2698
+ // the completion was verified but not recorded — they can re-engage.
2699
+ if (verdict && verdict.approved === true) {
2700
+ await announceAudit(
2701
+ sessionID,
2702
+ "Audit result: completion was approved but the goal was modified while the audit ran — completion not recorded.",
2703
+ )
2704
+ }
2705
+ return
2706
+ }
2509
2707
  if (!verdict || verdict.approved !== true) {
2510
2708
  const reason = (verdict && verdict.reason) || "completion not substantiated"
2511
2709
  auditedGoal.stopped = true
@@ -2525,8 +2723,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2525
2723
  )
2526
2724
  }
2527
2725
  activeGoalAfterMessages.lastStatus = "Goal completed."
2528
- // pushHistory writes the terminal event to the durable ledger first,
2529
- // so the completion survives even if the state write below fails.
2726
+ // pushHistory attempts to append the terminal event to the ledger before
2727
+ // the state write below. Note: ledger write failures are silent (bare
2728
+ // catch in emitLedgerEvent), and the ledger only enables recovery when
2729
+ // the state file is absent — a stale state file always takes precedence.
2530
2730
  pushHistory(
2531
2731
  activeGoalAfterMessages,
2532
2732
  "completed",
@@ -2608,6 +2808,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2608
2808
  // tokens), so it resets noProgressTurns the same way the noToolCall
2609
2809
  // gate already resets noToolCallTurns.
2610
2810
  const latestHasToolCall = messageHasToolCall(latestAssistant)
2811
+ // A turn that produced only reasoning tokens (no prose, no tool calls)
2812
+ // is an extended-thinking pass, not a stall. latestOutputTokens counts
2813
+ // prose output only; reasoning tokens are tracked separately. Without
2814
+ // this guard a pure-thinking turn matches lowOutputTurn (output=0 < threshold)
2815
+ // and latestText is empty, so it would false-positively look stalled.
2816
+ const latestHasThinkingTokens =
2817
+ toNonNegativeInteger(messageTokens(latestAssistant).reasoning) > 0
2611
2818
 
2612
2819
  const lowOutputTurn =
2613
2820
  activeGoalAfterMessages.turnCount > 0 &&
@@ -2618,13 +2825,25 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2618
2825
  // real work via tool calls. Excluding tool-call turns prevents false
2619
2826
  // noProgress pauses on thinking models.
2620
2827
  const lowOutputLooksStalled =
2621
- lowOutputTurn && !latestHasToolCall && (assistantRepeated || !latestText || !assistantChanged)
2828
+ lowOutputTurn &&
2829
+ !latestHasToolCall &&
2830
+ !latestHasThinkingTokens &&
2831
+ (assistantRepeated || !latestText || !assistantChanged)
2622
2832
  if (lowOutputLooksStalled) {
2623
2833
  activeGoalAfterMessages.noProgressTurns += 1
2624
2834
  if (
2625
2835
  activeGoalAfterMessages.noProgressTurns >=
2626
2836
  activeGoalAfterMessages.options.noProgressTurnsBeforePause
2627
2837
  ) {
2838
+ // Accumulate format-validation failures even when the stall gate fires
2839
+ // first and returns early, so the formatFailures cap remains reachable
2840
+ // for low-output unverified completions. Without this, a model that
2841
+ // repeatedly emits bare [goal:complete] with low output tokens causes
2842
+ // the stall gate to fire before formatFailures can accumulate, and
2843
+ // /goal resume resets it to zero, making the cap permanently unreachable.
2844
+ if (completionUnverified || blockerUnstated) {
2845
+ activeGoalAfterMessages.formatFailures += 1
2846
+ }
2628
2847
  activeGoalAfterMessages.stopped = true
2629
2848
  activeGoalAfterMessages.stopReason = "no progress"
2630
2849
  activeGoalAfterMessages.lastStatus = `Goal auto-continue paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s); the latest turn produced ${latestOutputTokens} output token(s). Run /${commandName} resume to continue.`
@@ -2643,7 +2862,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2643
2862
  "warning",
2644
2863
  `Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
2645
2864
  )
2646
- } else if (latestOutputTokens !== null || assistantChanged) {
2865
+ } else if (latestOutputTokens !== null || assistantChanged || !latestAssistant) {
2647
2866
  activeGoalAfterMessages.noProgressTurns = 0
2648
2867
  }
2649
2868
 
@@ -2653,9 +2872,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2653
2872
  // configured grace window. Complements the low-output check above:
2654
2873
  // a turn can be high-output yet still make no real progress because it
2655
2874
  // never touched a tool.
2875
+ // Guard on !lowOutputLooksStalled: if the noProgress gate already fired
2876
+ // for this turn, the noToolCall counter must NOT also increment. Without
2877
+ // this guard, the effective grace window is min(noProgress, noToolCall)
2878
+ // rather than two independent limits — the user's higher noProgress
2879
+ // threshold gets silently overridden by the lower noToolCall threshold.
2656
2880
  const noToolCallContinuation =
2657
2881
  activeGoalAfterMessages.turnCount > 0 && Boolean(latestAssistant) && !latestHasToolCall
2658
- if (noToolCallContinuation) {
2882
+ if (noToolCallContinuation && !lowOutputLooksStalled) {
2659
2883
  activeGoalAfterMessages.noToolCallTurns += 1
2660
2884
  if (
2661
2885
  activeGoalAfterMessages.noToolCallTurns >=
@@ -2679,7 +2903,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2679
2903
  "warning",
2680
2904
  `Observed a continuation turn with no tool calls; grace count ${activeGoalAfterMessages.noToolCallTurns}/${activeGoalAfterMessages.options.noToolCallTurnsBeforePause}.`,
2681
2905
  )
2682
- } else if (latestHasToolCall) {
2906
+ } else if (latestHasToolCall || !latestAssistant) {
2683
2907
  activeGoalAfterMessages.noToolCallTurns = 0
2684
2908
  }
2685
2909
 
@@ -2700,6 +2924,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2700
2924
  activeGoalBeforePrompt.stopped = true
2701
2925
  activeGoalBeforePrompt.stopReason = "budget wrap-up requested"
2702
2926
  activeGoalBeforePrompt.lastStatus = "Budget threshold reached; requested final handoff."
2927
+ // Persist before sending the wrapup prompt so that a crash during
2928
+ // promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
2929
+ // the hard-limit path which also persists before its promptAsync call.
2930
+ pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
2931
+ await persist()
2703
2932
  }
2704
2933
 
2705
2934
  activeGoalBeforePrompt.turnCount += 1
@@ -2712,7 +2941,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2712
2941
  activeGoalBeforePrompt.formatFailures += 1
2713
2942
  activeGoalBeforePrompt.lastStatus = `Rejected a [goal:blocked] with no concrete blocker; re-prompting on turn ${activeGoalBeforePrompt.turnCount}.`
2714
2943
  } else {
2715
- activeGoalBeforePrompt.formatFailures = 0
2944
+ // Decrement rather than reset: an alternating bad/good/bad pattern
2945
+ // should not indefinitely bypass the consecutive-failure cap. A model
2946
+ // that produces one clean turn for every violation keeps formatFailures
2947
+ // pinned near 1, which still accumulates toward the cap over time.
2948
+ activeGoalBeforePrompt.formatFailures = Math.max(
2949
+ 0,
2950
+ activeGoalBeforePrompt.formatFailures - 1,
2951
+ )
2716
2952
  activeGoalBeforePrompt.lastStatus = latestText
2717
2953
  ? `Continuing after assistant turn ${activeGoalBeforePrompt.turnCount}.`
2718
2954
  : `Continuing after idle event ${activeGoalBeforePrompt.turnCount}.`
@@ -2769,7 +3005,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2769
3005
  } else {
2770
3006
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
2771
3007
  if (activeGoalAfterPrompt) {
2772
- activeGoalAfterPrompt.promptFailures = 0
3008
+ // Decrement rather than reset: an alternating error/success pattern
3009
+ // should still accumulate toward the circuit-breaker cap over time,
3010
+ // matching the formatFailures approach for the same reason.
3011
+ activeGoalAfterPrompt.promptFailures = Math.max(0, activeGoalAfterPrompt.promptFailures - 1)
2773
3012
  pushHistory(
2774
3013
  activeGoalAfterPrompt,
2775
3014
  budgetWrapup ? "budget-wrapup" : "auto-continue",
@@ -2796,7 +3035,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2796
3035
  }
2797
3036
  await logPluginError(client, "Auto-continue failed", error)
2798
3037
  } finally {
2799
- activeContinues.delete(sessionID)
3038
+ // Only delete our own entry. If cleanupGoal already removed it (because
3039
+ // the goal completed) and a new handler has since set a fresh token,
3040
+ // we must not clobber the new handler's guard.
3041
+ if (activeContinues.get(sessionID) === continueToken) activeContinues.delete(sessionID)
2800
3042
  }
2801
3043
  },
2802
3044
 
@@ -2849,6 +3091,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2849
3091
  } else {
2850
3092
  output.context = [context]
2851
3093
  }
3094
+ // Reset the token high-water mark so the remaining budget reflects the
3095
+ // compacted context size, not the pre-compaction peak. Without this,
3096
+ // Math.max semantics mean totalTokens never decreases: a goal that crossed
3097
+ // the 80% wrapup threshold before compaction would permanently stay above it
3098
+ // even after the context shrinks to a fraction of its prior size.
3099
+ // Move current message IDs to priorMessageIDs so the message.updated guard
3100
+ // ignores stale events for pre-compaction messages.
3101
+ if (!goal.priorMessageIDs) goal.priorMessageIDs = new Set()
3102
+ for (const id of goal.messageIDs) goal.priorMessageIDs.add(id)
3103
+ goal.messageIDs = new Set()
3104
+ goal.totalTokens = 0
3105
+ await persist()
2852
3106
  },
2853
3107
 
2854
3108
  "experimental.compaction.autocontinue": async (input, output) => {