opencode-goal-plugin 0.6.8 → 0.8.0

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.
@@ -40,6 +40,9 @@ function legacyHomeStateFilePath(env = process.env) {
40
40
  return join(homeBase(env), ".opencode-goal-plugin", "state.json")
41
41
  }
42
42
  const MAX_HISTORY_ENTRIES = 20
43
+ // Marks a plugin-synthesized parent wake so the receiving pass knows it is
44
+ // re-examining an assistant turn that has already been scored.
45
+ const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake")
43
46
  const MAX_CHECKPOINTS = 5
44
47
  const CHECKPOINT_CHAR_LIMIT = 280
45
48
  const MAX_GOAL_OBJECTIVE_LENGTH = 4000
@@ -74,6 +77,8 @@ const DEFAULT_OPTIONS = {
74
77
  noProgressTokenThreshold: 50,
75
78
  noProgressTurnsBeforePause: 2,
76
79
  noToolCallTurnsBeforePause: 2,
80
+ noInterruptOnUserMessage: false,
81
+ noContinueWhileChildrenActive: false,
77
82
  budgetWrapupRatio: 0.8,
78
83
  warnTurnsRemaining: 3,
79
84
  warnDurationMsRemaining: 60 * 1000,
@@ -95,6 +100,7 @@ function createRuntimeState() {
95
100
  sessionArchive: new Map(),
96
101
  sessionOrdered: new Set(),
97
102
  lastGoalResults: new Map(),
103
+ sessionMutationVersions: new Map(),
98
104
  seenTokens: new Map(),
99
105
  seenUsage: new Map(),
100
106
  seenOutputTokens: new Map(),
@@ -165,6 +171,7 @@ const sessionArchive = runtimeCollection("sessionArchive")
165
171
  const sessionOrdered = runtimeCollection("sessionOrdered")
166
172
  const MAX_ARCHIVED_PER_SESSION = 10
167
173
  const lastGoalResults = runtimeCollection("lastGoalResults")
174
+ const sessionMutationVersions = runtimeCollection("sessionMutationVersions")
168
175
  const seenTokens = runtimeCollection("seenTokens")
169
176
  const seenUsage = runtimeCollection("seenUsage")
170
177
  const seenOutputTokens = runtimeCollection("seenOutputTokens")
@@ -490,6 +497,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
490
497
  options: goal.options,
491
498
  stopped: goal.stopped,
492
499
  stopReason: goal.stopReason,
500
+ blockedReason: goal.blockedReason,
493
501
  ordered: sessionOrdered.has(goal.sessionID),
494
502
  },
495
503
  type,
@@ -504,6 +512,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
504
512
  function pushHistory(goal, type, detail, timestamp = Date.now()) {
505
513
  const entry = makeHistoryEntry(type, detail, timestamp)
506
514
  goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES)
515
+ markSessionMutation(goal.sessionID)
507
516
  return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp)
508
517
  }
509
518
 
@@ -638,6 +647,7 @@ function reconstructGoalsFromLedger(entries) {
638
647
  const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim()
639
648
  if (!condition) continue
640
649
  const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {}
650
+ const latestBlocked = [...events].reverse().find((event) => event.type === "blocked")
641
651
 
642
652
  const history = events
643
653
  .map((event) =>
@@ -659,6 +669,12 @@ function reconstructGoalsFromLedger(entries) {
659
669
  options: isPlainObject(snapshot.options) ? snapshot.options : {},
660
670
  stopped: snapshot.stopped === true,
661
671
  stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "",
672
+ blockedReason:
673
+ typeof snapshot.blockedReason === "string"
674
+ ? snapshot.blockedReason
675
+ : snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string"
676
+ ? latestBlocked.detail
677
+ : "",
662
678
  ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))),
663
679
  startedAt: normalizeTimestamp(events[0]?.ts),
664
680
  history,
@@ -675,9 +691,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) {
675
691
  const checkpoint = { summary, timestamp }
676
692
  goal.lastCheckpoint = checkpoint
677
693
  goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
694
+ markSessionMutation(goal.sessionID)
678
695
  }
679
696
 
680
- function formatStatus(goal, commandName = "goal") {
697
+ function goalDisplayState(goal) {
698
+ if (!goal?.stopped) return "active"
699
+ return goal.stopReason === "blocked" ? "blocked" : "paused"
700
+ }
701
+
702
+ function formatStatus(
703
+ goal,
704
+ commandName = "goal",
705
+ completionAuditLabel = "evidence gate only (independent verifier off)",
706
+ ) {
681
707
  const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
682
708
  const lastProgress =
683
709
  goal.lastProgressAt > 0
@@ -688,6 +714,8 @@ function formatStatus(goal, commandName = "goal") {
688
714
  : "none yet"
689
715
  const lines = [
690
716
  `Active goal: ${goal.condition}`,
717
+ `State: ${goalDisplayState(goal)}`,
718
+ `Completion audit: ${completionAuditLabel}`,
691
719
  ]
692
720
  if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`)
693
721
  if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`)
@@ -772,8 +800,16 @@ function sessionGoalMap(sessionID) {
772
800
  return map
773
801
  }
774
802
 
803
+ function markSessionMutation(sessionID) {
804
+ if (!sessionID) return 0
805
+ const next = (sessionMutationVersions.get(sessionID) || 0) + 1
806
+ sessionMutationVersions.set(sessionID, next)
807
+ return next
808
+ }
809
+
775
810
  function registerSessionGoal(goal) {
776
811
  sessionGoalMap(goal.sessionID).set(goal.goalId, goal)
812
+ markSessionMutation(goal.sessionID)
777
813
  }
778
814
 
779
815
  function listSessionGoals(sessionID) {
@@ -796,12 +832,13 @@ function setBoundedMessageValue(map, messageID, value) {
796
832
  function removeSessionGoal(sessionID, goalId) {
797
833
  const map = sessionGoals.get(sessionID)
798
834
  if (!map) return
799
- map.delete(goalId)
835
+ if (map.delete(goalId)) markSessionMutation(sessionID)
800
836
  if (map.size === 0) sessionGoals.delete(sessionID)
801
837
  }
802
838
 
803
839
  function focusGoal(sessionID, goal) {
804
840
  goalStates.set(sessionID, goal)
841
+ markSessionMutation(sessionID)
805
842
  }
806
843
 
807
844
  function pauseGoalClock(goal, timestamp = Date.now()) {
@@ -858,6 +895,10 @@ function cleanupGoal(sessionID) {
858
895
  }
859
896
  goalStates.delete(sessionID)
860
897
  activeContinues.delete(sessionID)
898
+ // Increment even when no focused goal remains. A concurrent clear of a
899
+ // provisional completion is otherwise indistinguishable from unrelated
900
+ // global result-retention pruning while its terminal write is in flight.
901
+ markSessionMutation(sessionID)
861
902
  }
862
903
 
863
904
  function clearRuntimeState() {
@@ -868,6 +909,7 @@ function clearRuntimeState() {
868
909
  sessionArchive.clear()
869
910
  sessionOrdered.clear()
870
911
  lastGoalResults.clear()
912
+ sessionMutationVersions.clear()
871
913
  seenTokens.clear()
872
914
  seenUsage.clear()
873
915
  seenOutputTokens.clear()
@@ -908,6 +950,7 @@ function clearSessionRuntimeState(
908
950
  runtime.sessionStatuses.delete(sessionID)
909
951
  if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
910
952
  runtime.passiveSessions.delete(sessionID)
953
+ markSessionMutation(sessionID)
911
954
  if (!preserveCommandSecurity) {
912
955
  runtime.pendingCommandTurns.delete(sessionID)
913
956
  runtime.activeCommandTurns.delete(sessionID)
@@ -966,16 +1009,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
966
1009
  lastGoalResults.delete(sessionID)
967
1010
  lastGoalResults.set(sessionID, result)
968
1011
  // Keep a per-session archive so completed goals stay readable via /goal list.
969
- archiveSessionResult(sessionID, { ...result })
1012
+ const archivedResult = { ...result }
1013
+ archiveSessionResult(sessionID, archivedResult)
970
1014
  pruneGoalResults(goal.options)
1015
+ markSessionMutation(sessionID)
1016
+ return { lastResult: result, archivedResult }
971
1017
  }
972
1018
 
973
- function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = false } = {}) {
974
- lastGoalResults.delete(sessionID)
1019
+ function captureFocusedGoalSnapshot(sessionID) {
1020
+ const goal = goalStates.get(sessionID) || null
1021
+ return {
1022
+ goal,
1023
+ serialized: goal ? JSON.stringify(serializeGoal(goal)) : "",
1024
+ mutationVersion: sessionMutationVersions.get(sessionID) || 0,
1025
+ }
1026
+ }
1027
+
1028
+ function focusedGoalSnapshotIsCurrent(sessionID, snapshot) {
1029
+ const current = goalStates.get(sessionID) || null
1030
+ if (current !== snapshot?.goal) return false
1031
+ if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false
1032
+ return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized
1033
+ }
1034
+
1035
+ function restoreAfterTerminalPersistenceFailure(
1036
+ sessionID,
1037
+ goal,
1038
+ { ordered = false, expectedCurrentSnapshot, expectedResult } = {},
1039
+ ) {
1040
+ // A terminal write can yield while another command replaces, edits, pauses,
1041
+ // resumes, clears, or advances the session. Never roll the old goal back over
1042
+ // that newer state. The per-session mutation version catches a concurrent
1043
+ // clear even when both the expected and current focused goal are null, while
1044
+ // remaining unaffected by result-retention pruning in a different session.
1045
+ const expectedLastResult = expectedResult?.lastResult || expectedResult
1046
+ const expectedArchivedResult = expectedResult?.archivedResult
1047
+ const canRestore =
1048
+ !expectedCurrentSnapshot ||
1049
+ focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot)
1050
+
1051
+ // Remove only this failed provisional completion record. A newer concurrent
1052
+ // result/archive entry belongs to the newer operation and must survive.
1053
+ if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) {
1054
+ lastGoalResults.delete(sessionID)
1055
+ }
975
1056
  const archived = sessionArchive.get(sessionID) || []
976
- if (archived.length) {
1057
+ if (expectedArchivedResult) {
1058
+ const retained = archived.filter((entry) => entry !== expectedArchivedResult)
1059
+ if (retained.length) sessionArchive.set(sessionID, retained)
1060
+ else sessionArchive.delete(sessionID)
1061
+ } else if (archived.length) {
977
1062
  sessionArchive.set(sessionID, archived.slice(0, -1))
978
1063
  }
1064
+
1065
+ if (!canRestore) return false
979
1066
  const prematurelyPromoted = goalStates.get(sessionID)
980
1067
  if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) {
981
1068
  prematurelyPromoted.stopped = true
@@ -990,6 +1077,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal
990
1077
  goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry."
991
1078
  registerSessionGoal(goal)
992
1079
  focusGoal(sessionID, goal)
1080
+ return true
993
1081
  }
994
1082
 
995
1083
  function resetGoalBudget(goal) {
@@ -1093,6 +1181,8 @@ function normalizeOptions(options = {}) {
1093
1181
  Number.isSafeInteger(options.noToolCallTurnsBeforePause) && options.noToolCallTurnsBeforePause >= 0
1094
1182
  ? options.noToolCallTurnsBeforePause
1095
1183
  : DEFAULT_OPTIONS.noToolCallTurnsBeforePause,
1184
+ noInterruptOnUserMessage: options.noInterruptOnUserMessage === true,
1185
+ noContinueWhileChildrenActive: options.noContinueWhileChildrenActive === true,
1096
1186
  budgetWrapupRatio:
1097
1187
  Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1
1098
1188
  ? Number(options.budgetWrapupRatio)
@@ -1543,15 +1633,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1543
1633
  }
1544
1634
 
1545
1635
  // After applyParsedStateFile loads goals into goalStates, check the ledger for
1546
- // terminal events. If a goal has a "completed" or "cleared" entry in the ledger
1547
- // but still appears active in the state file (because the state write failed
1548
- // after the terminal ledger write), remove it so it is not re-driven.
1636
+ // state transitions that landed after the snapshot. Completed/cleared goals are
1637
+ // removed so they cannot be re-driven, while a newer blocked event is overlaid
1638
+ // so its state and concrete reason survive a failed snapshot write.
1549
1639
  async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
1550
1640
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1551
1641
  maxBytes: persistenceOptions.ledgerMaxBytes,
1552
1642
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
1553
1643
  })
1554
- if (!entries.length) return
1644
+ if (!entries.length) return { removed: 0, blocked: 0 }
1555
1645
 
1556
1646
  const terminalGoals = new Set()
1557
1647
  for (const entry of entries) {
@@ -1564,16 +1654,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
1564
1654
  terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
1565
1655
  }
1566
1656
  }
1567
- if (!terminalGoals.size) return
1568
-
1569
1657
  let removed = 0
1658
+ let blocked = 0
1570
1659
  for (const [sessionID, goals] of sessionGoals.entries()) {
1571
1660
  if (onlySessionID && sessionID !== onlySessionID) continue
1572
1661
  for (const goal of [...goals.values()]) {
1573
- if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
1574
- removeSessionGoal(sessionID, goal.goalId)
1575
- if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
1576
- removed += 1
1662
+ const key = `${sessionID}\0${goal.goalId}`
1663
+ if (terminalGoals.has(key)) {
1664
+ removeSessionGoal(sessionID, goal.goalId)
1665
+ if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
1666
+ removed += 1
1667
+ continue
1668
+ }
1669
+
1670
+ const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered")
1671
+ const latestPersistedTimestamp = persistedHistory.reduce(
1672
+ (latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)),
1673
+ 0,
1674
+ )
1675
+ let latestLedgerState = null
1676
+ let latestLedgerTimestamp = -1
1677
+ for (const entry of entries) {
1678
+ if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue
1679
+ const timestamp = normalizeTimestamp(entry.ts, 0)
1680
+ if (timestamp < latestPersistedTimestamp) continue
1681
+ const detail = summarizeText(entry.detail, 400)
1682
+ const alreadyApplied = persistedHistory.some(
1683
+ (event) =>
1684
+ event.type === entry.type &&
1685
+ normalizeTimestamp(event.timestamp, 0) === timestamp &&
1686
+ event.detail === detail,
1687
+ )
1688
+ if (timestamp >= latestLedgerTimestamp) {
1689
+ latestLedgerState = { entry, alreadyApplied }
1690
+ latestLedgerTimestamp = timestamp
1691
+ }
1692
+ }
1693
+ if (
1694
+ latestLedgerState?.alreadyApplied ||
1695
+ latestLedgerState?.entry?.type !== "blocked" ||
1696
+ latestLedgerState.entry.snapshot?.stopped !== true ||
1697
+ latestLedgerState.entry.snapshot?.stopReason !== "blocked"
1698
+ ) continue
1699
+
1700
+ const reason = summarizeText(
1701
+ latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail,
1702
+ MAX_GOAL_BLOCKER_LENGTH,
1703
+ )
1704
+ if (!reason) continue
1705
+ goal.stopped = true
1706
+ goal.stopReason = "blocked"
1707
+ goal.blockedReason = reason
1708
+ goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind."
1709
+ goal.continuationClaim = null
1710
+ goal.history = [
1711
+ ...(goal.history || []),
1712
+ makeHistoryEntry(
1713
+ "blocked",
1714
+ reason,
1715
+ normalizeTimestamp(latestLedgerState.entry.ts),
1716
+ ),
1717
+ ].slice(-MAX_HISTORY_ENTRIES)
1718
+ pauseGoalClock(goal)
1719
+ blocked += 1
1577
1720
  }
1578
1721
  if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) {
1579
1722
  promoteNextOrderedGoal(sessionID)
@@ -1585,6 +1728,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
1585
1728
  `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).`,
1586
1729
  )
1587
1730
  }
1731
+ if (blocked > 0) {
1732
+ await logPluginError(
1733
+ client,
1734
+ `Ledger cross-check: restored ${blocked} blocked goal(s) whose blocked state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`,
1735
+ )
1736
+ }
1737
+ return { removed, blocked }
1588
1738
  }
1589
1739
 
1590
1740
  async function pathExists(path) {
@@ -1810,8 +1960,8 @@ async function loadPersistedSessionState(persistence, client, sessionID) {
1810
1960
  const state = await readPersistedStateFile(persistence.stateFilePath, client)
1811
1961
  if (state.status === "loaded") {
1812
1962
  await applyParsedStateFile(state.raw, client, sessionID)
1813
- await reconcileLoadedStateWithLedger(persistence, client, sessionID)
1814
- return "loaded"
1963
+ const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID)
1964
+ return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded"
1815
1965
  }
1816
1966
  const recovered = await reconstructFromLedger(persistence, client, sessionID)
1817
1967
  if (state.status === "invalid" && recovered === "reconstructed") {
@@ -2802,6 +2952,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2802
2952
  }
2803
2953
 
2804
2954
  const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
2955
+ const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived."
2956
+ const AGENT_BLOCK_SUCCESS = "Goal marked blocked."
2805
2957
 
2806
2958
  // Programmatic equivalents of the /goal command, exposed to the agent as tools
2807
2959
  // Each handler operates on a session id and mutates
@@ -2810,14 +2962,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
2810
2962
  // result. Goal creation/replacement routes through the multi-goal registry
2811
2963
  // (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
2812
2964
  // path, so tool-created goals persist and are driven by the idle handler.
2813
- function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState = null, completionAuditor = null, commandName = "goal" }) {
2965
+ function buildAgentToolHandlers({
2966
+ defaultGoalOptions,
2967
+ persist,
2968
+ persistTerminalState = null,
2969
+ completionAuditor = null,
2970
+ completionAuditLabel = "evidence gate only (independent verifier off)",
2971
+ announceAudit = async () => {},
2972
+ auditMessagesEnabled = false,
2973
+ announceLifecycle = () => {},
2974
+ commandName = "goal",
2975
+ }) {
2814
2976
  // Use persistTerminalState (which logs on failure) for terminal operations when
2815
2977
  // available; fall back to plain persist for callers that don't wire it up (e.g.
2816
2978
  // tests using buildAgentToolHandlers directly).
2817
2979
  const persistFinal = persistTerminalState || persist
2818
2980
  async function getGoal(sessionID) {
2819
2981
  const goal = goalStates.get(sessionID)
2820
- if (goal) return formatStatus(goal)
2982
+ if (goal) return formatStatus(goal, commandName, completionAuditLabel)
2821
2983
  const lastResult = lastGoalResults.get(sessionID)
2822
2984
  if (lastResult) return formatGoalResult(lastResult)
2823
2985
  return "No active goal."
@@ -2887,12 +3049,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2887
3049
  // Mirror the `/goal <condition>` replace path: discard the focused goal and
2888
3050
  // its saved result, drop any ordered sequence, then register + focus the new
2889
3051
  // goal so it persists and the idle handler drives it.
3052
+ const replacedGoal = goalStates.get(sessionID)
2890
3053
  sessionOrdered.delete(sessionID)
2891
3054
  cleanupGoal(sessionID)
2892
3055
  lastGoalResults.delete(sessionID)
2893
3056
  registerSessionGoal(goal)
2894
3057
  focusGoal(sessionID, goal)
2895
3058
  await persist(sessionID)
3059
+ announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
3060
+ goal,
3061
+ transition: replacedGoal ? "replaced-active" : "active",
3062
+ expectedState: "active",
3063
+ })
2896
3064
  // Escape in the tool result only: goal.condition is stored raw so callers
2897
3065
  // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
2898
3066
  // themselves. Escaping here prevents XML metacharacters in user-supplied
@@ -2920,6 +3088,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2920
3088
  }
2921
3089
 
2922
3090
  const messages = []
3091
+ let lifecycleNotice = null
2923
3092
 
2924
3093
  if (typeof args.objective === "string" && args.objective.trim()) {
2925
3094
  if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
@@ -2938,6 +3107,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2938
3107
  goal.lastStatus = "Goal objective updated."
2939
3108
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
2940
3109
  messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
3110
+ lifecycleNotice = {
3111
+ text: `Goal updated; state remains ${goalDisplayState(goal)}.`,
3112
+ transition: "updated",
3113
+ reason: goalDisplayState(goal),
3114
+ expectedState: goalDisplayState(goal),
3115
+ expectedStopReason: goal.stopped ? goal.stopReason : "",
3116
+ }
2941
3117
  }
2942
3118
 
2943
3119
  if (args.status !== undefined) {
@@ -2950,13 +3126,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2950
3126
  if (!evidence) return "Completion evidence is required before a goal can be archived."
2951
3127
  if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
2952
3128
  return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
3129
+ const auditedGoalID = goal.goalId
3130
+ const auditedRunID = goal.runId
3131
+ if (auditMessagesEnabled) {
3132
+ await announceAudit(
3133
+ sessionID,
3134
+ "Auditing goal completion: checking submitted evidence before archiving.",
3135
+ )
3136
+ const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID)
3137
+ if (!goalAfterAnnouncement) {
3138
+ return "Completion audit finished after the goal changed; completion was not recorded."
3139
+ }
3140
+ goal = goalAfterAnnouncement
3141
+ }
2953
3142
  // If a completion auditor is configured, run it before archiving so the
2954
3143
  // agent tool path has the same integrity gate as the [goal:complete] marker
2955
3144
  // path. Without this, an autonomous agent could bypass the auditor by
2956
3145
  // calling update_goal({status:"complete"}) instead of using the marker.
2957
3146
  if (completionAuditor) {
2958
- const auditedGoalID = goal.goalId
2959
- const auditedRunID = goal.runId
2960
3147
  let verdict
2961
3148
  try {
2962
3149
  verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
@@ -2975,6 +3162,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2975
3162
  goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
2976
3163
  pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
2977
3164
  await persist(sessionID)
3165
+ const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID)
3166
+ if (
3167
+ rejectedGoalAfterPersist !== goal ||
3168
+ !goal.stopped ||
3169
+ goal.stopReason !== "audit rejected"
3170
+ ) {
3171
+ return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched."
3172
+ }
3173
+ if (auditMessagesEnabled) {
3174
+ await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
3175
+ } else {
3176
+ announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
3177
+ goal,
3178
+ transition: "audit-rejected",
3179
+ reason,
3180
+ expectedState: "paused",
3181
+ expectedStopReason: "audit rejected",
3182
+ })
3183
+ }
2978
3184
  return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
2979
3185
  }
2980
3186
  }
@@ -2985,16 +3191,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2985
3191
  evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
2986
3192
  )
2987
3193
  const ordered = sessionOrdered.has(sessionID)
2988
- rememberGoalResult(sessionID, goal, "achieved", "", evidence)
3194
+ const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence)
2989
3195
  cleanupGoal(sessionID)
2990
3196
  // Advance an ordered sequence just like the marker path does.
2991
- if (ordered) promoteNextOrderedGoal(sessionID)
3197
+ const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
3198
+ const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
2992
3199
  const durable = await persistFinal(sessionID, "completion", ledgerDurable)
2993
3200
  if (durable === false) {
2994
- restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
2995
- return "Completion verified, but terminal state could not be persisted. Goal remains paused."
3201
+ const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, {
3202
+ ordered,
3203
+ expectedCurrentSnapshot: postCompletionSnapshot,
3204
+ expectedResult: completedResult,
3205
+ })
3206
+ if (auditMessagesEnabled) {
3207
+ await announceAudit(
3208
+ sessionID,
3209
+ restored
3210
+ ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
3211
+ : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
3212
+ )
3213
+ } else {
3214
+ announceLifecycle(
3215
+ sessionID,
3216
+ restored
3217
+ ? "Goal paused — completion could not be recorded durably."
3218
+ : "Previous goal completion could not be confirmed durably after goal state changed.",
3219
+ restored
3220
+ ? {
3221
+ goal,
3222
+ transition: "terminal-persistence-failed",
3223
+ reason: goal.stopReason,
3224
+ expectedState: "paused",
3225
+ expectedStopReason: "terminal persistence failed",
3226
+ }
3227
+ : {
3228
+ transition: "terminal-persistence-raced",
3229
+ requireCurrent: false,
3230
+ },
3231
+ )
3232
+ }
3233
+ return restored
3234
+ ? "Completion verified, but terminal state could not be persisted. Goal remains paused."
3235
+ : "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched."
3236
+ }
3237
+ const activePromoted = promoted
3238
+ ? activeGoal(sessionID, promoted.goalId, promoted.runId)
3239
+ : null
3240
+ if (auditMessagesEnabled) {
3241
+ await announceAudit(
3242
+ sessionID,
3243
+ activePromoted
3244
+ ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
3245
+ : "Audit result: completion accepted — goal archived as achieved.",
3246
+ )
3247
+ } else {
3248
+ announceLifecycle(
3249
+ sessionID,
3250
+ activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
3251
+ {
3252
+ goal: activePromoted || goal,
3253
+ transition: activePromoted ? "achieved-promoted" : "achieved",
3254
+ requireCurrent: Boolean(activePromoted),
3255
+ expectedState: activePromoted ? "active" : "",
3256
+ },
3257
+ )
2996
3258
  }
2997
- return "Goal marked complete and archived."
3259
+ return AGENT_COMPLETE_SUCCESS
2998
3260
  }
2999
3261
  if (status === "blocked") {
3000
3262
  const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
@@ -3002,18 +3264,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
3002
3264
  return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
3003
3265
  if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
3004
3266
  return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
3267
+ const blockedGoalID = goal.goalId
3268
+ const blockedRunID = goal.runId
3269
+ if (auditMessagesEnabled) {
3270
+ await announceAudit(
3271
+ sessionID,
3272
+ "Auditing goal blocker: checking the submitted blocker before pausing.",
3273
+ )
3274
+ const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID)
3275
+ if (!goalAfterAnnouncement) {
3276
+ return "Blocker audit finished after the goal changed; blocked state was not recorded."
3277
+ }
3278
+ goal = goalAfterAnnouncement
3279
+ }
3005
3280
  goal.blockedReason = blockerText
3006
3281
  goal.stopped = true
3007
3282
  goal.stopReason = "blocked"
3008
3283
  goal.lastStatus = "Assistant reported blocked."
3009
- pushHistory(goal, "blocked", goal.blockedReason)
3010
- messages.push("Goal marked blocked.")
3284
+ const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason)
3285
+ messages.push(AGENT_BLOCK_SUCCESS)
3286
+ const durable = await persistFinal(sessionID, "blocked", ledgerDurable)
3287
+ const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID)
3288
+ if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") {
3289
+ return "Blocked state changed while persistence completed; blocked state was not reported."
3290
+ }
3291
+ if (durable === false) {
3292
+ goal.stopReason = "terminal persistence failed"
3293
+ goal.lastStatus = "Blocked state could not be persisted; goal remains paused."
3294
+ if (auditMessagesEnabled) {
3295
+ await announceAudit(
3296
+ sessionID,
3297
+ "Audit result: blocker recognized, but storage failed; goal remains paused.",
3298
+ )
3299
+ } else {
3300
+ announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
3301
+ goal,
3302
+ transition: "terminal-persistence-failed",
3303
+ expectedState: "paused",
3304
+ expectedStopReason: "terminal persistence failed",
3305
+ })
3306
+ }
3307
+ return "Blocker recognized, but terminal state could not be persisted. Goal remains paused."
3308
+ }
3309
+ if (auditMessagesEnabled) {
3310
+ await announceAudit(
3311
+ sessionID,
3312
+ `Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`,
3313
+ )
3314
+ } else {
3315
+ announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
3316
+ goal,
3317
+ transition: "blocked",
3318
+ expectedState: "blocked",
3319
+ expectedStopReason: "blocked",
3320
+ })
3321
+ }
3322
+ return messages.join(" ")
3011
3323
  } else if (status === "paused") {
3012
- goal.stopped = true
3013
- goal.stopReason = "paused"
3014
- goal.lastStatus = "Goal paused."
3015
- pushHistory(goal, "paused", "Paused via agent tool.")
3016
- messages.push("Goal paused.")
3324
+ if (goal.stopped && goal.stopReason === "paused") {
3325
+ if (!messages.length) return "Goal is already paused."
3326
+ messages.push("Goal is already paused.")
3327
+ } else {
3328
+ goal.stopped = true
3329
+ goal.stopReason = "paused"
3330
+ goal.lastStatus = "Goal paused."
3331
+ pushHistory(goal, "paused", "Paused via agent tool.")
3332
+ messages.push("Goal paused.")
3333
+ lifecycleNotice = {
3334
+ text: "Goal paused.",
3335
+ transition: "paused",
3336
+ reason: goal.stopReason,
3337
+ expectedState: "paused",
3338
+ expectedStopReason: "paused",
3339
+ }
3340
+ }
3017
3341
  } else if (status === "resumed") {
3018
3342
  if (!goal.stopped)
3019
3343
  return "Goal is already running. Pause or stop it first if you want to reset the budget window."
@@ -3027,6 +3351,11 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
3027
3351
  goal.lastStatus = "Goal resumed with a fresh local budget."
3028
3352
  pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
3029
3353
  messages.push("Goal resumed with fresh limits.")
3354
+ lifecycleNotice = {
3355
+ text: "Goal resumed with fresh limits.",
3356
+ transition: "resumed",
3357
+ expectedState: "active",
3358
+ }
3030
3359
  }
3031
3360
  }
3032
3361
 
@@ -3034,6 +3363,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
3034
3363
  return "Nothing to update. Provide `objective` and/or `status`."
3035
3364
  }
3036
3365
  await persist(sessionID)
3366
+ if (lifecycleNotice) {
3367
+ announceLifecycle(sessionID, lifecycleNotice.text, {
3368
+ goal,
3369
+ transition: lifecycleNotice.transition,
3370
+ reason: lifecycleNotice.reason,
3371
+ expectedState: lifecycleNotice.expectedState,
3372
+ expectedStopReason: lifecycleNotice.expectedStopReason,
3373
+ })
3374
+ }
3037
3375
  return messages.join(" ")
3038
3376
  }
3039
3377
 
@@ -3042,15 +3380,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
3042
3380
  // focused goal + result. Without sessionGoals.delete, background goals added via
3043
3381
  // `/goal add` survive clear and resurrect as the focused goal on restart.
3044
3382
  // Record the clear in the ledger before cleanupGoal removes the goal object.
3045
- for (const goal of listSessionGoals(sessionID)) {
3046
- pushHistory(goal, "cleared", "Cleared via agent tool.")
3047
- }
3383
+ const goals = listSessionGoals(sessionID)
3384
+ const clearedGoal = goalStates.get(sessionID) || goals[0] || null
3385
+ const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
3386
+ const ledgerDurable =
3387
+ goals.length > 0 &&
3388
+ goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean)
3048
3389
  sessionOrdered.delete(sessionID)
3049
3390
  sessionGoals.delete(sessionID)
3050
3391
  cleanupGoal(sessionID)
3051
3392
  lastGoalResults.delete(sessionID)
3052
- await persistFinal(sessionID, "clear")
3053
- return "Goal cleared."
3393
+ const durable = await persistFinal(sessionID, "clear", ledgerDurable)
3394
+ const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
3395
+ if (hadState && clearStillCurrent) {
3396
+ announceLifecycle(sessionID, durable === false
3397
+ ? "Goal cleared in memory, but storage failed; it may reappear after restart."
3398
+ : "Goal cleared.", {
3399
+ goal: clearedGoal,
3400
+ transition: durable === false ? "clear-persistence-failed" : "cleared",
3401
+ requireCurrent: false,
3402
+ })
3403
+ }
3404
+ if (!clearStillCurrent) {
3405
+ return "Clear persistence finished after goal state changed; current state was left untouched."
3406
+ }
3407
+ return durable === false
3408
+ ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
3409
+ : "Goal cleared."
3054
3410
  }
3055
3411
 
3056
3412
  return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
@@ -3175,8 +3531,22 @@ function buildAgentTools(
3175
3531
  return goalToolFailure("already_running", "Goal is already running.")
3176
3532
  }
3177
3533
  const message = await handlers.updateGoal(sessionID, args)
3178
- if (args.status === "complete" && currentGoal(sessionID)) {
3179
- return goalToolFailure("completion_rejected", message)
3534
+ if (args.status === "complete") {
3535
+ if (
3536
+ message !== AGENT_COMPLETE_SUCCESS ||
3537
+ currentGoal(sessionID, before.goalId, before.runId)
3538
+ ) {
3539
+ return goalToolFailure("completion_rejected", message)
3540
+ }
3541
+ }
3542
+ if (args.status === "blocked") {
3543
+ const after = currentGoal(sessionID, before.goalId, before.runId)
3544
+ if (after !== before) {
3545
+ return goalToolFailure("goal_changed", message)
3546
+ }
3547
+ if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") {
3548
+ return goalToolFailure("block_rejected", message)
3549
+ }
3180
3550
  }
3181
3551
  return goalToolSuccess(message)
3182
3552
  },
@@ -3296,8 +3666,14 @@ function formatGoalList(sessionID, commandName = "goal") {
3296
3666
  lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
3297
3667
  goals.forEach((goal, index) => {
3298
3668
  const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
3299
- const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
3300
- lines.push(`${index + 1}. [${marker}] ${goal.condition}${state}`)
3669
+ const state = goalDisplayState(goal)
3670
+ const reason = state === "blocked"
3671
+ ? goal.blockedReason || goal.stopReason
3672
+ : goal.stopped
3673
+ ? goal.stopReason
3674
+ : ""
3675
+ const reasonText = reason ? ` (${summarizeText(reason, 160)})` : ""
3676
+ lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`)
3301
3677
  })
3302
3678
  lines.push(`Switch with \`/${commandName} focus <number>\`.`)
3303
3679
  } else {
@@ -3343,6 +3719,37 @@ async function defaultAuditMessenger(client, sessionID, text) {
3343
3719
  }
3344
3720
  }
3345
3721
 
3722
+ // High-signal lifecycle feedback uses the same non-blocking host surfaces as
3723
+ // audit notices, but remains a separate channel so callers can configure each
3724
+ // independently. Messages are normalized and bounded before they reach either
3725
+ // host API; goal objectives, evidence, and workspace paths are deliberately
3726
+ // excluded by transition call sites.
3727
+ async function defaultLifecycleMessenger(client, sessionID, text) {
3728
+ const message = summarizeText(text, 500)
3729
+ const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message)
3730
+ const success = /\b(?:achieved|completed)\b/i.test(message)
3731
+ if (client?.app?.log) {
3732
+ dispatchAdvisoryHostCall(() => client.app.log({
3733
+ body: {
3734
+ service: "opencode-goal-plugin",
3735
+ level: warning ? "warn" : "info",
3736
+ message,
3737
+ extra: { sessionID, kind: "goal-lifecycle" },
3738
+ },
3739
+ }))
3740
+ }
3741
+ if (client?.tui?.showToast) {
3742
+ dispatchAdvisoryHostCall(() => client.tui.showToast({
3743
+ body: {
3744
+ title: "Goal workflow",
3745
+ message,
3746
+ variant: warning ? "warning" : success ? "success" : "info",
3747
+ duration: 6000,
3748
+ },
3749
+ }))
3750
+ }
3751
+ }
3752
+
3346
3753
  // Completion auditor. When an auditor is configured, a [goal:complete]
3347
3754
  // is verified before the goal is archived: an approved verdict archives it, a
3348
3755
  // rejected verdict restores the goal (pauses it with the reason) instead of
@@ -3499,6 +3906,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3499
3906
  return persistence.persistChain
3500
3907
  }
3501
3908
 
3909
+ const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false
3910
+ const lifecycleMessenger =
3911
+ typeof pluginOptions.lifecycleMessenger === "function"
3912
+ ? pluginOptions.lifecycleMessenger
3913
+ : (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text)
3914
+ const announceLifecycle = (
3915
+ sessionID,
3916
+ text,
3917
+ {
3918
+ goal,
3919
+ transition = "state",
3920
+ reason = "",
3921
+ requireCurrent = true,
3922
+ expectedState = "",
3923
+ expectedStopReason = "",
3924
+ } = {},
3925
+ ) => {
3926
+ if (!lifecycleMessagesEnabled || !sessionID) return false
3927
+ if (requireCurrent && goal) {
3928
+ const current = goalStates.get(sessionID)
3929
+ if (current !== goal) return false
3930
+ if (expectedState && goalDisplayState(current) !== expectedState) return false
3931
+ if (expectedStopReason && current.stopReason !== expectedStopReason) return false
3932
+ }
3933
+ const message = summarizeText(text, 500)
3934
+ if (!message) return false
3935
+ dispatchAdvisoryHostCall(
3936
+ () => lifecycleMessenger(sessionID, message),
3937
+ (error) => {
3938
+ void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {})
3939
+ },
3940
+ )
3941
+ return true
3942
+ }
3943
+
3502
3944
  const passiveLoadResult = (entry) => ({
3503
3945
  kind: "passive",
3504
3946
  code: SESSION_OWNED_ELSEWHERE,
@@ -3596,7 +4038,40 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3596
4038
  const status = await loadPersistedSessionState(persistence, client, sessionID)
3597
4039
  if (runtime.disposed) return releaseDisposedSession()
3598
4040
  pruneGoalResults(defaultGoalOptions)
3599
- if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID)
4041
+ if (
4042
+ status === "loaded" ||
4043
+ status === "missing" ||
4044
+ status === "reconstructed" ||
4045
+ status === "reconciled-blocked"
4046
+ ) await persist(sessionID)
4047
+ const recoveredGoal = goalStates.get(sessionID)
4048
+ if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") {
4049
+ announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, {
4050
+ goal: recoveredGoal,
4051
+ transition: "recovered-paused",
4052
+ reason: recoveredGoal.stopReason,
4053
+ expectedState: "paused",
4054
+ expectedStopReason: "recovered after restart",
4055
+ })
4056
+ } else if (
4057
+ status === "reconciled-blocked" &&
4058
+ recoveredGoal?.stopped &&
4059
+ recoveredGoal.stopReason === "blocked"
4060
+ ) {
4061
+ announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, {
4062
+ goal: recoveredGoal,
4063
+ transition: "recovered-blocked",
4064
+ reason: recoveredGoal.blockedReason,
4065
+ expectedState: "blocked",
4066
+ expectedStopReason: "blocked",
4067
+ })
4068
+ } else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") {
4069
+ announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", {
4070
+ goal: recoveredGoal,
4071
+ transition: "recovered-promoted",
4072
+ expectedState: "active",
4073
+ })
4074
+ }
3600
4075
  if (runtime.disposed) return releaseDisposedSession()
3601
4076
  return ACTIVE_PERSISTENCE_OWNED
3602
4077
  } catch (error) {
@@ -3681,6 +4156,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3681
4156
  reason: "owned verifier agent registration was not confirmed",
3682
4157
  })
3683
4158
  : null
4159
+ const completionAuditLabel =
4160
+ typeof pluginOptions.auditor === "function"
4161
+ ? "custom completion auditor"
4162
+ : pluginOptions.completionAudit
4163
+ ? "built-in independent verifier"
4164
+ : "evidence gate only (independent verifier off)"
3684
4165
 
3685
4166
  clearRuntimeState()
3686
4167
 
@@ -3689,6 +4170,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3689
4170
  persist,
3690
4171
  persistTerminalState,
3691
4172
  completionAuditor,
4173
+ completionAuditLabel,
4174
+ announceAudit,
4175
+ auditMessagesEnabled,
4176
+ announceLifecycle,
3692
4177
  commandName,
3693
4178
  })
3694
4179
 
@@ -3714,7 +4199,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3714
4199
  ) => {
3715
4200
  const goal = goalStates.get(sessionID)
3716
4201
  if (!goal) return false
4202
+ if (goal.stopped && goal.stopReason === reason) return false
3717
4203
  currentRuntime().continuationControllers.get(sessionID)?.abort()
4204
+ // A goal stopping while deferred must release its watched children, or the
4205
+ // watch outlives the goal and a later child idle re-drives a dead loop.
4206
+ clearDeferredChildren(sessionID)
4207
+ childDeferralNotices.delete(childDeferralKey(sessionID, goal))
3718
4208
  goal.stopped = true
3719
4209
  goal.stopReason = reason
3720
4210
  goal.lastStatus = `${status} Run /${commandName} resume to continue.`
@@ -3722,10 +4212,187 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3722
4212
  pushHistory(goal, "paused", history)
3723
4213
  activeContinues.delete(sessionID)
3724
4214
  await persist(sessionID)
4215
+ announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, {
4216
+ goal,
4217
+ transition: "paused",
4218
+ reason,
4219
+ expectedState: "paused",
4220
+ expectedStopReason: reason,
4221
+ })
3725
4222
  if (abortAccepted) await abortAcceptedContinuation(sessionID)
3726
4223
  return true
3727
4224
  }
3728
4225
 
4226
+ // A child counts as active only when the host reports a non-idle status for
4227
+ // it. OpenCode drops idle sessions from the `/session/status` map, so bare
4228
+ // key presence happens to work today, but the SDK response type is
4229
+ // `{[id: string]: SessionStatus}` and `SessionStatus` includes `{type:
4230
+ // "idle"}`. A host that reports idle children explicitly would otherwise
4231
+ // gate every continuation forever and stall the goal with no diagnostics.
4232
+ // Unknown/unparseable status shapes stay "active" so the gate errs toward
4233
+ // deferring rather than double-driving a session a child is working in.
4234
+ const childStatusIsActive = (statusMap, childID) => {
4235
+ if (!Object.hasOwn(statusMap, childID)) return false
4236
+ const status = statusMap[childID]
4237
+ return !(isPlainObject(status) && status.type === "idle")
4238
+ }
4239
+
4240
+ // Hosts that cannot report children/status fail open, but the failure is a
4241
+ // property of the host, not of a single turn: logging it on every
4242
+ // continuation attempt would add one identical error per goal turn.
4243
+ const childActivityProbeFailuresLogged = new Set()
4244
+ const logChildActivityProbeFailure = (kind, message, error) => {
4245
+ if (childActivityProbeFailuresLogged.has(kind)) return Promise.resolve()
4246
+ childActivityProbeFailuresLogged.add(kind)
4247
+ return logPluginError(
4248
+ client,
4249
+ `${message} (further ${kind} failures are suppressed for this plugin instance)`,
4250
+ error,
4251
+ )
4252
+ }
4253
+
4254
+ // With noContinueWhileChildrenActive, auto-continue is deferred while any
4255
+ // child session (subagent, background task) is still active, so the goal
4256
+ // loop does not prompt the orchestrator over work a child is already doing.
4257
+ // Fail open: if the host cannot report children/status, continue as before.
4258
+ const activeChildSessionIDs = async (sessionID) => {
4259
+ try {
4260
+ const [children, status] = await Promise.all([
4261
+ sessionApi.children(sessionID),
4262
+ sessionApi.status(),
4263
+ ])
4264
+ // A live opencode SDK does not throw on an argument-shape mismatch; it
4265
+ // resolves with `{error, request, response}` and no `data`. Treating that
4266
+ // silently as "no children" would turn the whole gate into a no-op with
4267
+ // no diagnostic, so an unusable payload takes the same logged fail-open
4268
+ // path as a thrown error.
4269
+ if (!Array.isArray(children) || !isPlainObject(status)) {
4270
+ await logChildActivityProbeFailure(
4271
+ "payload",
4272
+ "Child session activity probe returned an unusable payload; continuing without the active-children gate",
4273
+ new Error(
4274
+ `children=${Array.isArray(children) ? "array" : typeof children}, status=${isPlainObject(status) ? "object" : typeof status}`,
4275
+ ),
4276
+ )
4277
+ return []
4278
+ }
4279
+ return children
4280
+ .filter(
4281
+ (child) =>
4282
+ isPlainObject(child) &&
4283
+ typeof child.id === "string" &&
4284
+ childStatusIsActive(status, child.id),
4285
+ )
4286
+ .map((child) => child.id)
4287
+ } catch (error) {
4288
+ await logChildActivityProbeFailure(
4289
+ "probe",
4290
+ "Failed to check child session activity; continuing without the active-children gate",
4291
+ error,
4292
+ )
4293
+ return []
4294
+ }
4295
+ }
4296
+
4297
+ // Deferral is only announced on the transition into and out of the gated
4298
+ // state. Without this the goal reports itself as running while doing nothing
4299
+ // at all, which is indistinguishable from a hang in `/goal status`.
4300
+ const childDeferralNotices = new Set()
4301
+ const childDeferralKey = (sessionID, goal) =>
4302
+ `${sessionID}\u0000${goal.goalId}\u0000${goal.runId}`
4303
+
4304
+ // Idle events are session-scoped and a child's completion is delivered only
4305
+ // on the child's own session: a parent that is already idle emits nothing at
4306
+ // all while a child runs and finishes (verified against a live opencode
4307
+ // server). Because the continuation driver is purely event-driven, a goal
4308
+ // deferred behind a child would never be retried. Remember the children we
4309
+ // deferred on so their idle event can re-drive the parent exactly once.
4310
+ // Entries carry the goal identity, not just the parent session: `cleanupGoal`
4311
+ // runs on clear/replace/complete from many call sites, so rather than hooking
4312
+ // every one of them the wake path re-validates that the goal which deferred is
4313
+ // still the goal in focus. A stale entry is dropped instead of driving a
4314
+ // continuation for a goal that never deferred.
4315
+ const MAX_DEFERRED_CHILD_WATCH = 256
4316
+ const deferredChildWatch = new Map()
4317
+ // Monotonic marker for idle events seen from sessions that hold no goal. The
4318
+ // probe is asynchronous, so a child can go idle between the status snapshot
4319
+ // and the watch being armed: its event arrives with nothing armed, is
4320
+ // dropped, and the watch is then set on a session that will never emit again.
4321
+ // Recording the sequence at which each child was last seen idle lets the gate
4322
+ // notice that and continue instead of waiting forever.
4323
+ // Guards the synthesized parent wake below against re-entering itself. Keyed
4324
+ // by parent session: the wake is awaited across several SDK round-trips, and
4325
+ // a single shared counter would drop every other parent's wake arriving in
4326
+ // that window — a permanent strand, silently, in an unrelated goal.
4327
+ const childWakeInFlight = new Set()
4328
+ let idleEventSequence = 0
4329
+ const childIdleSequence = new Map()
4330
+ const recordChildIdle = (childSessionID) => {
4331
+ if (!childSessionID) return
4332
+ idleEventSequence += 1
4333
+ childIdleSequence.set(childSessionID, idleEventSequence)
4334
+ while (childIdleSequence.size > MAX_DEFERRED_CHILD_WATCH) {
4335
+ childIdleSequence.delete(childIdleSequence.keys().next().value)
4336
+ }
4337
+ }
4338
+ const idledSince = (childSessionID, sequence) =>
4339
+ (childIdleSequence.get(childSessionID) ?? 0) > sequence
4340
+ // Returns false when the children cannot all be tracked. Deferring without a
4341
+ // complete watch would strand the goal the moment an untracked child is the
4342
+ // one that finishes, so the caller continues instead. Capacity is never
4343
+ // reclaimed by evicting a live entry: that is the same silent strand seen
4344
+ // from the other direction.
4345
+ const watchDeferredChildren = (sessionID, goal, childIDs) => {
4346
+ for (const [childID, watched] of deferredChildWatch) {
4347
+ if (watched.sessionID === sessionID && !childIDs.includes(childID)) {
4348
+ deferredChildWatch.delete(childID)
4349
+ }
4350
+ }
4351
+ pruneDeferredChildState()
4352
+ let otherSessionEntries = 0
4353
+ for (const watched of deferredChildWatch.values()) {
4354
+ if (watched.sessionID !== sessionID) otherSessionEntries += 1
4355
+ }
4356
+ if (otherSessionEntries + childIDs.length > MAX_DEFERRED_CHILD_WATCH) return false
4357
+ for (const childID of childIDs) {
4358
+ deferredChildWatch.set(childID, {
4359
+ sessionID,
4360
+ goalId: goal.goalId,
4361
+ runId: goal.runId,
4362
+ })
4363
+ }
4364
+ return true
4365
+ }
4366
+
4367
+ // Bounded like every other runtime map in this file, but eviction must never
4368
+ // discard a watch a live goal is waiting on: that would strand it with no
4369
+ // diagnostic, which is the failure this whole mechanism exists to prevent.
4370
+ // Entries whose goal has been cleared, replaced, completed or stopped are
4371
+ // dead weight and are dropped first; the cap is only enforced against live
4372
+ // entries as a last resort.
4373
+ const deferralGoalIsLive = (watched) => {
4374
+ const goal = goalStates.get(watched.sessionID)
4375
+ return Boolean(
4376
+ goal && goal.goalId === watched.goalId && goal.runId === watched.runId && !goal.stopped,
4377
+ )
4378
+ }
4379
+ const pruneDeferredChildState = () => {
4380
+ for (const [childID, watched] of deferredChildWatch) {
4381
+ if (!deferralGoalIsLive(watched)) deferredChildWatch.delete(childID)
4382
+ }
4383
+ for (const key of childDeferralNotices) {
4384
+ const [noticeSessionID, goalId, runId] = key.split("\u0000")
4385
+ if (!deferralGoalIsLive({ sessionID: noticeSessionID, goalId, runId })) {
4386
+ childDeferralNotices.delete(key)
4387
+ }
4388
+ }
4389
+ }
4390
+ const clearDeferredChildren = (sessionID) => {
4391
+ for (const [childID, watched] of deferredChildWatch) {
4392
+ if (watched.sessionID === sessionID) deferredChildWatch.delete(childID)
4393
+ }
4394
+ }
4395
+
3729
4396
  const claimContinuationSource = async (
3730
4397
  sessionID,
3731
4398
  goalID,
@@ -3760,10 +4427,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3760
4427
  return null
3761
4428
  }
3762
4429
 
4430
+ // Human intervention is evaluated before the active-children gate: a real
4431
+ // user message must pause the goal immediately, not once the subagents
4432
+ // happen to go idle.
3763
4433
  const newHumanMessage =
3764
4434
  refreshed.latestRealUserMessageID &&
3765
4435
  refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
3766
- if (newHumanMessage || userInterventionDetected(messages, goal)) {
4436
+ if (
4437
+ !goal.options.noInterruptOnUserMessage &&
4438
+ (newHumanMessage || userInterventionDetected(messages, goal))
4439
+ ) {
4440
+ childDeferralNotices.delete(childDeferralKey(sessionID, goal))
3767
4441
  await pauseActiveGoal(sessionID, {
3768
4442
  stopReason: "user intervention",
3769
4443
  status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
@@ -3772,6 +4446,70 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3772
4446
  return null
3773
4447
  }
3774
4448
 
4449
+ if (goal.options.noContinueWhileChildrenActive) {
4450
+ const deferralKey = childDeferralKey(sessionID, goal)
4451
+ const sequenceBeforeProbe = idleEventSequence
4452
+ let activeChildren = await activeChildSessionIDs(sessionID)
4453
+ // A child running a goal of its own consumes its idle events for that
4454
+ // goal, so it cannot deliver the wake this gate depends on. Deferring
4455
+ // behind one would strand the parent silently; the gate steps aside.
4456
+ const selfDrivenChildren = activeChildren.filter((childID) => goalStates.has(childID))
4457
+ if (selfDrivenChildren.length > 0) {
4458
+ await logChildActivityProbeFailure(
4459
+ "self-driven-child",
4460
+ `Active child session(s) ${selfDrivenChildren.join(", ")} run goals of their own and cannot wake this goal; continuing without the active-children gate`,
4461
+ new Error("watched child holds its own goal state"),
4462
+ )
4463
+ activeChildren = []
4464
+ }
4465
+ if (activeChildren.length > 0) {
4466
+ // Arm the watch, then confirm the children are still active. A child
4467
+ // that went idle while the first probe was in flight would already have
4468
+ // delivered its event, finding nothing armed, and the goal would wait
4469
+ // for a wake-up that can never come. Re-probing after arming closes
4470
+ // that window: from here on any transition is observed by the watch.
4471
+ if (!watchDeferredChildren(sessionID, goal, activeChildren)) {
4472
+ // More concurrent children than the watch can hold. Continuing is the
4473
+ // safe direction: the gate is an optimisation, a stranded goal is not.
4474
+ await logChildActivityProbeFailure(
4475
+ "watch-capacity",
4476
+ `Cannot track ${activeChildren.length} active child session(s) within the watch limit; continuing without the active-children gate`,
4477
+ new Error(`watch limit ${MAX_DEFERRED_CHILD_WATCH} exceeded`),
4478
+ )
4479
+ activeChildren = []
4480
+ } else {
4481
+ activeChildren = await activeChildSessionIDs(sessionID)
4482
+ // Drop any child that went idle while a probe was in flight: its wake
4483
+ // event has already been delivered and will not come again.
4484
+ activeChildren = activeChildren.filter(
4485
+ (childID) => !idledSince(childID, sequenceBeforeProbe),
4486
+ )
4487
+ }
4488
+ }
4489
+ if (activeChildren.length > 0) {
4490
+ if (!childDeferralNotices.has(deferralKey)) {
4491
+ childDeferralNotices.add(deferralKey)
4492
+ goal.lastStatus =
4493
+ "Auto-continue deferred while a child session (subagent or background task) is still active. The goal is still running and continues once the children finish."
4494
+ // One entry per episode, not one per transition: history is a
4495
+ // 20-entry ring and a subagent-heavy run would otherwise evict
4496
+ // checkpoints and limit warnings.
4497
+ pushHistory(
4498
+ goal,
4499
+ "deferred",
4500
+ "Deferred auto-continue while child sessions were active.",
4501
+ )
4502
+ await persist(sessionID)
4503
+ }
4504
+ return null
4505
+ }
4506
+ clearDeferredChildren(sessionID)
4507
+ if (childDeferralNotices.delete(deferralKey)) {
4508
+ goal.lastStatus = "Child sessions went idle; auto-continue resumed."
4509
+ await persist(sessionID)
4510
+ }
4511
+ }
4512
+
3775
4513
  if (
3776
4514
  refreshed.latestAssistantID !== baseline.latestAssistantID ||
3777
4515
  refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
@@ -3798,6 +4536,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3798
4536
  goal.stopReason = "continuation claim persistence failed"
3799
4537
  goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
3800
4538
  pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
4539
+ announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", {
4540
+ goal,
4541
+ transition: "continuation-persistence-failed",
4542
+ reason: goal.stopReason,
4543
+ expectedState: "paused",
4544
+ expectedStopReason: "continuation claim persistence failed",
4545
+ })
3801
4546
  return null
3802
4547
  }
3803
4548
  return goal
@@ -3929,6 +4674,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3929
4674
 
3930
4675
  const goal = goalStates.get(sessionID)
3931
4676
  if (!goal || goal.stopped) return
4677
+ // With noInterruptOnUserMessage, a human message steers the running loop
4678
+ // instead of pausing the goal for /goal resume.
4679
+ if (goal.options.noInterruptOnUserMessage) return
3932
4680
  await pauseActiveGoal(sessionID, {
3933
4681
  stopReason: "user intervention",
3934
4682
  status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
@@ -3992,7 +4740,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3992
4740
  replaceCommandOutputText(
3993
4741
  output,
3994
4742
  goal
3995
- ? formatStatus(goal, commandName)
4743
+ ? formatStatus(goal, commandName, completionAuditLabel)
3996
4744
  : lastResult
3997
4745
  ? formatGoalResult(lastResult)
3998
4746
  : `No active goal. Set one with \`/${commandName} <condition>\`.`,
@@ -4033,15 +4781,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4033
4781
  // sessionGoals.delete clears ALL backgrounded goals so they do not
4034
4782
  // resurrect as the focused goal on restart (cleanupGoal only removes the
4035
4783
  // focused one; background goals from `/goal add` would survive otherwise).
4036
- for (const goal of listSessionGoals(sessionID)) {
4037
- pushHistory(goal, "cleared", "User cleared the goal.")
4038
- }
4784
+ const goals = listSessionGoals(sessionID)
4785
+ const clearedGoal = goalStates.get(sessionID) || goals[0] || null
4786
+ const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
4787
+ const ledgerDurable =
4788
+ goals.length > 0 &&
4789
+ goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean)
4039
4790
  sessionOrdered.delete(sessionID)
4040
4791
  sessionGoals.delete(sessionID)
4041
4792
  cleanupGoal(sessionID)
4042
4793
  lastGoalResults.delete(sessionID)
4043
- await persist(sessionID)
4044
- replaceCommandOutputText(output, "Goal cleared.")
4794
+ const durable = await persistTerminalState(sessionID, "clear", ledgerDurable)
4795
+ const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
4796
+ if (hadState && clearStillCurrent) {
4797
+ announceLifecycle(sessionID, durable === false
4798
+ ? "Goal cleared in memory, but storage failed; it may reappear after restart."
4799
+ : "Goal cleared.", {
4800
+ goal: clearedGoal,
4801
+ transition: durable === false ? "clear-persistence-failed" : "cleared",
4802
+ requireCurrent: false,
4803
+ })
4804
+ }
4805
+ replaceCommandOutputText(
4806
+ output,
4807
+ !clearStillCurrent
4808
+ ? "Clear persistence finished after goal state changed; current state was left untouched."
4809
+ : durable === false
4810
+ ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
4811
+ : "Goal cleared.",
4812
+ )
4045
4813
  return
4046
4814
  }
4047
4815
 
@@ -4051,6 +4819,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4051
4819
  replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} <condition>\`.`)
4052
4820
  return
4053
4821
  }
4822
+ if (goal.stopped && goal.stopReason === "paused") {
4823
+ replaceCommandOutputText(output, "Goal is already paused.")
4824
+ return
4825
+ }
4054
4826
  currentRuntime().continuationControllers.get(sessionID)?.abort()
4055
4827
  goal.stopped = true
4056
4828
  goal.stopReason = "paused"
@@ -4059,6 +4831,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4059
4831
  activeContinues.delete(sessionID)
4060
4832
  pushHistory(goal, "paused", "User paused the active goal.")
4061
4833
  await persist(sessionID)
4834
+ announceLifecycle(sessionID, "Goal paused.", {
4835
+ goal,
4836
+ transition: "paused",
4837
+ reason: goal.stopReason,
4838
+ expectedState: "paused",
4839
+ expectedStopReason: "paused",
4840
+ })
4062
4841
  await abortAcceptedContinuation(sessionID)
4063
4842
  replaceCommandOutputText(output, `Goal paused: ${goal.condition}`)
4064
4843
  return
@@ -4085,6 +4864,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4085
4864
  goal.lastStatus = "Goal resumed with a fresh local budget."
4086
4865
  pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
4087
4866
  await persist(sessionID)
4867
+ announceLifecycle(sessionID, "Goal resumed with fresh limits.", {
4868
+ goal,
4869
+ transition: "resumed",
4870
+ expectedState: "active",
4871
+ })
4088
4872
  replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, {
4089
4873
  startsWork: true,
4090
4874
  })
@@ -4132,6 +4916,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4132
4916
  goal.lastStatus = "Goal objective updated."
4133
4917
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
4134
4918
  await persist(sessionID)
4919
+ announceLifecycle(sessionID, "Goal updated and active.", {
4920
+ goal,
4921
+ transition: "updated-active",
4922
+ expectedState: "active",
4923
+ })
4135
4924
  replaceCommandOutputText(
4136
4925
  output,
4137
4926
  [
@@ -4212,6 +5001,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4212
5001
  focusGoal(sessionID, firstGoal)
4213
5002
  sessionOrdered.add(sessionID)
4214
5003
  await persist(sessionID)
5004
+ announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, {
5005
+ goal: firstGoal,
5006
+ transition: "sequence-active",
5007
+ reason: String(objectives.length),
5008
+ expectedState: "active",
5009
+ })
4215
5010
  replaceCommandOutputText(
4216
5011
  output,
4217
5012
  [
@@ -4277,6 +5072,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4277
5072
  pushHistory(target, "focused", "Brought into focus as the session's active goal.")
4278
5073
  focusGoal(sessionID, target)
4279
5074
  await persist(sessionID)
5075
+ announceLifecycle(sessionID, "Goal focus changed; selected goal active.", {
5076
+ goal: target,
5077
+ transition: "focused-active",
5078
+ expectedState: "active",
5079
+ })
4280
5080
  replaceCommandOutputText(
4281
5081
  output,
4282
5082
  [
@@ -4335,6 +5135,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4335
5135
  registerSessionGoal(added)
4336
5136
  focusGoal(sessionID, added)
4337
5137
  await persist(sessionID)
5138
+ announceLifecycle(sessionID, current
5139
+ ? "Goal added and active; previous goal backgrounded."
5140
+ : "Goal added and active.", {
5141
+ goal: added,
5142
+ transition: current ? "added-active-backgrounded" : "added-active",
5143
+ expectedState: "active",
5144
+ })
4338
5145
  const total = listSessionGoals(sessionID).length
4339
5146
  replaceCommandOutputText(
4340
5147
  output,
@@ -4373,6 +5180,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4373
5180
  registerSessionGoal(goal)
4374
5181
  focusGoal(sessionID, goal)
4375
5182
  await persist(sessionID)
5183
+ announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
5184
+ goal,
5185
+ transition: replacedGoal ? "replaced-active" : "active",
5186
+ expectedState: "active",
5187
+ })
4376
5188
  replaceCommandOutputText(
4377
5189
  output,
4378
5190
  [
@@ -4590,12 +5402,62 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4590
5402
 
4591
5403
  if (!isIdleEvent(event)) return
4592
5404
 
4593
- const sessionID = getSessionID(event)
5405
+ const emittingSessionID = getSessionID(event)
5406
+ let sessionID = emittingSessionID
5407
+ // A child we deferred on has gone idle. The parent emits no event of its
5408
+ // own, so this is the only chance to re-drive its continuation. Consumed
5409
+ // once: unrelated children (the completion auditor's own session, for
5410
+ // example) are never watched and so can never trigger a continuation.
5411
+ let childWakeEvent = event?.[CHILD_WAKE_EVENT_FLAG] === true
5412
+ if (sessionID && !goalStates.has(sessionID)) recordChildIdle(sessionID)
5413
+ if (sessionID && deferredChildWatch.has(sessionID)) {
5414
+ const watched = deferredChildWatch.get(sessionID)
5415
+ deferredChildWatch.delete(sessionID)
5416
+ // The goal that deferred must still be the goal in focus. If it was
5417
+ // cleared, replaced, completed or restarted in the meantime, this wake
5418
+ // belongs to nothing and must not drive the goal that took its place.
5419
+ const parentGoal = goalStates.get(watched.sessionID)
5420
+ const parentStillWaiting =
5421
+ parentGoal &&
5422
+ parentGoal.goalId === watched.goalId &&
5423
+ parentGoal.runId === watched.runId
5424
+ if (parentStillWaiting && !goalStates.has(sessionID)) {
5425
+ sessionID = watched.sessionID
5426
+ childWakeEvent = true
5427
+ } else if (
5428
+ parentStillWaiting &&
5429
+ !childWakeInFlight.has(watched.sessionID) &&
5430
+ currentRuntime().sessionStatuses.get(watched.sessionID) === "idle"
5431
+ ) {
5432
+ // The child acquired a goal of its own after being watched, so it
5433
+ // needs this event for its own loop. Serving only one of the two
5434
+ // would starve the other, so the parent is woken through a
5435
+ // synthesized idle of its own before the child's event continues.
5436
+ childWakeInFlight.add(watched.sessionID)
5437
+ try {
5438
+ await hooks.event({
5439
+ event: {
5440
+ type: "session.idle",
5441
+ properties: { sessionID: watched.sessionID },
5442
+ // B: the synthesized event is a wake pass like any other, so it
5443
+ // must not re-charge the stall gates for an assistant turn the
5444
+ // deferring pass already scored.
5445
+ [CHILD_WAKE_EVENT_FLAG]: true,
5446
+ },
5447
+ })
5448
+ } finally {
5449
+ childWakeInFlight.delete(watched.sessionID)
5450
+ }
5451
+ }
5452
+ }
4594
5453
  // Deprecated session.idle carries no status object but is itself an
4595
5454
  // authoritative idle signal. Current session.status events were recorded
4596
- // above before entering this branch.
5455
+ // above before entering this branch. Record it against the session that
5456
+ // actually emitted it: a child going idle says nothing about whether its
5457
+ // parent is idle, and claiming otherwise would defeat the idle guard in
5458
+ // the continuation claim.
4597
5459
  if (event?.type === "session.idle") {
4598
- currentRuntime().sessionStatuses.set(sessionID, "idle")
5460
+ currentRuntime().sessionStatuses.set(emittingSessionID, "idle")
4599
5461
  }
4600
5462
  const eventID = typeof event?.id === "string" ? event.id : ""
4601
5463
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
@@ -4672,7 +5534,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4672
5534
  // Latest instruction wins: if a real (non-plugin) user message arrived
4673
5535
  // since the last auto-continue, stop driving the loop and defer to the
4674
5536
  // human. They can /goal resume to hand control back to the plugin.
4675
- if (userInterventionDetected(messages, activeGoalAfterMessages)) {
5537
+ if (
5538
+ !activeGoalAfterMessages.options.noInterruptOnUserMessage &&
5539
+ userInterventionDetected(messages, activeGoalAfterMessages)
5540
+ ) {
4676
5541
  await pauseActiveGoal(sessionID, {
4677
5542
  stopReason: "user intervention",
4678
5543
  status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
@@ -4740,7 +5605,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4740
5605
  auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
4741
5606
  pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
4742
5607
  await persist(sessionID)
4743
- await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
5608
+ const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
5609
+ if (
5610
+ rejectedGoalAfterPersist !== auditedGoal ||
5611
+ !auditedGoal.stopped ||
5612
+ auditedGoal.stopReason !== "audit rejected"
5613
+ ) return
5614
+ if (auditMessagesEnabled) {
5615
+ await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
5616
+ } else {
5617
+ announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
5618
+ goal: auditedGoal,
5619
+ transition: "audit-rejected",
5620
+ reason,
5621
+ expectedState: "paused",
5622
+ expectedStopReason: "audit rejected",
5623
+ })
5624
+ }
4744
5625
  return
4745
5626
  }
4746
5627
  pushHistory(
@@ -4760,23 +5641,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4760
5641
  `Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`,
4761
5642
  )
4762
5643
  const ordered = sessionOrdered.has(sessionID)
4763
- rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
5644
+ const completedResult = rememberGoalResult(
5645
+ sessionID,
5646
+ activeGoalAfterMessages,
5647
+ "achieved",
5648
+ "",
5649
+ evidence,
5650
+ )
4764
5651
  cleanupGoal(sessionID)
4765
5652
  // Ordered sequence: auto-promote the next goal so the
4766
5653
  // session keeps working through the sequence without manual /goal focus.
4767
- if (ordered) {
4768
- promoteNextOrderedGoal(sessionID)
4769
- }
5654
+ const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
5655
+ const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
4770
5656
  const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
4771
5657
  if (durable === false) {
4772
- restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
4773
- await announceAudit(
5658
+ const restored = restoreAfterTerminalPersistenceFailure(
4774
5659
  sessionID,
4775
- "Audit result: completion verified, but storage failed; goal remains paused and was not archived.",
5660
+ activeGoalAfterMessages,
5661
+ {
5662
+ ordered,
5663
+ expectedCurrentSnapshot: postCompletionSnapshot,
5664
+ expectedResult: completedResult,
5665
+ },
4776
5666
  )
5667
+ if (auditMessagesEnabled) {
5668
+ await announceAudit(
5669
+ sessionID,
5670
+ restored
5671
+ ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
5672
+ : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
5673
+ )
5674
+ } else {
5675
+ announceLifecycle(
5676
+ sessionID,
5677
+ restored
5678
+ ? "Goal paused — completion could not be recorded durably."
5679
+ : "Previous goal completion could not be confirmed durably after goal state changed.",
5680
+ restored
5681
+ ? {
5682
+ goal: activeGoalAfterMessages,
5683
+ transition: "terminal-persistence-failed",
5684
+ reason: activeGoalAfterMessages.stopReason,
5685
+ expectedState: "paused",
5686
+ expectedStopReason: "terminal persistence failed",
5687
+ }
5688
+ : {
5689
+ transition: "terminal-persistence-raced",
5690
+ requireCurrent: false,
5691
+ },
5692
+ )
5693
+ }
4777
5694
  return
4778
5695
  }
4779
- await announceAudit(sessionID, "Audit result: completion accepted — goal archived as achieved.")
5696
+ const activePromoted = promoted
5697
+ ? activeGoal(sessionID, promoted.goalId, promoted.runId)
5698
+ : null
5699
+ if (auditMessagesEnabled) {
5700
+ await announceAudit(
5701
+ sessionID,
5702
+ activePromoted
5703
+ ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
5704
+ : "Audit result: completion accepted — goal archived as achieved.",
5705
+ )
5706
+ } else {
5707
+ announceLifecycle(
5708
+ sessionID,
5709
+ activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
5710
+ {
5711
+ goal: activePromoted || activeGoalAfterMessages,
5712
+ transition: activePromoted ? "achieved-promoted" : "achieved",
5713
+ requireCurrent: Boolean(activePromoted),
5714
+ expectedState: activePromoted ? "active" : "",
5715
+ },
5716
+ )
5717
+ }
4780
5718
  return
4781
5719
  }
4782
5720
  completionUnverified = true
@@ -4802,16 +5740,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4802
5740
  blockedGoal.stopReason = "blocked"
4803
5741
  const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
4804
5742
  const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
5743
+ const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
5744
+ if (
5745
+ blockedGoalAfterPersist !== blockedGoal ||
5746
+ !blockedGoal.stopped ||
5747
+ blockedGoal.stopReason !== "blocked"
5748
+ ) return
4805
5749
  if (durable === false) {
4806
5750
  blockedGoal.stopReason = "terminal persistence failed"
4807
5751
  blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
4808
- await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
5752
+ if (auditMessagesEnabled) {
5753
+ await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
5754
+ } else {
5755
+ announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
5756
+ goal: blockedGoal,
5757
+ transition: "terminal-persistence-failed",
5758
+ reason: blockedGoal.stopReason,
5759
+ expectedState: "paused",
5760
+ expectedStopReason: "terminal persistence failed",
5761
+ })
5762
+ }
4809
5763
  return
4810
5764
  }
4811
- await announceAudit(
4812
- sessionID,
4813
- `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
4814
- )
5765
+ if (auditMessagesEnabled) {
5766
+ await announceAudit(
5767
+ sessionID,
5768
+ `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
5769
+ )
5770
+ } else {
5771
+ announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
5772
+ goal: blockedGoal,
5773
+ transition: "blocked",
5774
+ expectedState: "blocked",
5775
+ expectedStopReason: "blocked",
5776
+ })
5777
+ }
4815
5778
  return
4816
5779
  }
4817
5780
  blockerUnstated = true
@@ -4826,6 +5789,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4826
5789
 
4827
5790
  const limitReason = stopReason(activeGoalAfterMessages)
4828
5791
  if (limitReason) {
5792
+ let lifecycleAnnounced = false
4829
5793
  if (!activeGoalAfterMessages.budgetWrapupSent) {
4830
5794
  const claimedGoal = await claimContinuationSource(
4831
5795
  sessionID,
@@ -4842,6 +5806,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4842
5806
  claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
4843
5807
  pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
4844
5808
  await persist(sessionID)
5809
+ lifecycleAnnounced = announceLifecycle(
5810
+ sessionID,
5811
+ `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`,
5812
+ {
5813
+ goal: claimedGoal,
5814
+ transition: "limit-paused",
5815
+ reason: limitReason,
5816
+ expectedState: "paused",
5817
+ expectedStopReason: limitReason,
5818
+ },
5819
+ )
4845
5820
  currentRuntime().promptInFlightSessions.add(sessionID)
4846
5821
  let response
4847
5822
  try {
@@ -4868,6 +5843,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4868
5843
  pushHistory(activeGoalAfterMessages, "limit", limitReason)
4869
5844
  }
4870
5845
  await persist(sessionID)
5846
+ if (!lifecycleAnnounced) {
5847
+ announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, {
5848
+ goal: activeGoalAfterMessages,
5849
+ transition: "limit-paused",
5850
+ reason: limitReason,
5851
+ expectedState: "paused",
5852
+ expectedStopReason: limitReason,
5853
+ })
5854
+ }
4871
5855
  return
4872
5856
  }
4873
5857
 
@@ -4899,7 +5883,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4899
5883
  !latestHasToolCall &&
4900
5884
  !latestHasThinkingTokens &&
4901
5885
  (assistantRepeated || !latestText || !assistantChanged)
4902
- if (lowOutputLooksStalled) {
5886
+ // A child-wake pass re-examines an assistant turn the parent already
5887
+ // produced and was already charged for: the parent ran nothing in
5888
+ // between. Charging the stall gates again would pause a healthy goal
5889
+ // after one talk-only turn plus one deferral.
5890
+ if (lowOutputLooksStalled && !childWakeEvent) {
4903
5891
  activeGoalAfterMessages.noProgressTurns += 1
4904
5892
  if (
4905
5893
  activeGoalAfterMessages.noProgressTurns >=
@@ -4923,6 +5911,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4923
5911
  `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
4924
5912
  )
4925
5913
  await persist(sessionID)
5914
+ announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", {
5915
+ goal: activeGoalAfterMessages,
5916
+ transition: "no-progress-paused",
5917
+ reason: activeGoalAfterMessages.stopReason,
5918
+ expectedState: "paused",
5919
+ expectedStopReason: "no progress",
5920
+ })
4926
5921
  return
4927
5922
  }
4928
5923
 
@@ -4932,7 +5927,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4932
5927
  "warning",
4933
5928
  `Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
4934
5929
  )
4935
- } else if (latestOutputTokens !== null || assistantChanged || !latestAssistant) {
5930
+ } else if (
5931
+ // A wake pass observes the same assistant turn the deferring pass
5932
+ // already scored, so it must neither charge nor clear the counter.
5933
+ // Resetting here would let an alternating defer/wake cycle keep a
5934
+ // genuinely stalled loop running indefinitely.
5935
+ !childWakeEvent &&
5936
+ (latestOutputTokens !== null || assistantChanged || !latestAssistant)
5937
+ ) {
4936
5938
  activeGoalAfterMessages.noProgressTurns = 0
4937
5939
  }
4938
5940
 
@@ -4953,7 +5955,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4953
5955
  !activationBoundary &&
4954
5956
  Boolean(latestAssistant) &&
4955
5957
  !latestHasToolCall
4956
- if (noToolCallContinuation && !lowOutputLooksStalled) {
5958
+ if (noToolCallContinuation && !lowOutputLooksStalled && !childWakeEvent) {
4957
5959
  activeGoalAfterMessages.noToolCallTurns += 1
4958
5960
  if (
4959
5961
  activeGoalAfterMessages.noToolCallTurns >=
@@ -4968,6 +5970,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4968
5970
  `Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
4969
5971
  )
4970
5972
  await persist(sessionID)
5973
+ announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", {
5974
+ goal: activeGoalAfterMessages,
5975
+ transition: "no-tool-calls-paused",
5976
+ reason: activeGoalAfterMessages.stopReason,
5977
+ expectedState: "paused",
5978
+ expectedStopReason: "no tool calls",
5979
+ })
4971
5980
  return
4972
5981
  }
4973
5982
 
@@ -5017,6 +6026,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5017
6026
  // the hard-limit path which also persists before its promptAsync call.
5018
6027
  pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
5019
6028
  await persist(sessionID)
6029
+ announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", {
6030
+ goal: activeGoalBeforePrompt,
6031
+ transition: "budget-wrapup-paused",
6032
+ reason: activeGoalBeforePrompt.stopReason,
6033
+ expectedState: "paused",
6034
+ expectedStopReason: "budget wrap-up requested",
6035
+ })
5020
6036
  }
5021
6037
 
5022
6038
  activeGoalBeforePrompt.turnCount += 1
@@ -5057,6 +6073,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5057
6073
  `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
5058
6074
  )
5059
6075
  await persist(sessionID)
6076
+ announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", {
6077
+ goal: activeGoalBeforePrompt,
6078
+ transition: "format-failures-paused",
6079
+ reason: activeGoalBeforePrompt.stopReason,
6080
+ expectedState: "paused",
6081
+ expectedStopReason: "format validation failures",
6082
+ })
5060
6083
  return
5061
6084
  }
5062
6085
  }
@@ -5081,6 +6104,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5081
6104
  currentRuntime().promptInFlightSessions.delete(sessionID)
5082
6105
  }
5083
6106
 
6107
+ let promptFailurePausedGoal = null
5084
6108
  if (response.error) {
5085
6109
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
5086
6110
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
@@ -5096,6 +6120,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5096
6120
  activeGoalAfterPrompt.stopped = true
5097
6121
  activeGoalAfterPrompt.stopReason = "auto-continue failures"
5098
6122
  activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.`
6123
+ promptFailurePausedGoal = activeGoalAfterPrompt
5099
6124
  }
5100
6125
  }
5101
6126
  await logPluginError(client, message, response.error)
@@ -5119,6 +6144,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5119
6144
  }
5120
6145
  }
5121
6146
  await persist(sessionID)
6147
+ if (promptFailurePausedGoal) {
6148
+ announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
6149
+ goal: promptFailurePausedGoal,
6150
+ transition: "prompt-failures-paused",
6151
+ reason: promptFailurePausedGoal.stopReason,
6152
+ expectedState: "paused",
6153
+ expectedStopReason: "auto-continue failures",
6154
+ })
6155
+ }
5122
6156
  } catch (error) {
5123
6157
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
5124
6158
  if (activeGoalAfterError) {
@@ -5139,6 +6173,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5139
6173
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
5140
6174
  }
5141
6175
  await persist(sessionID)
6176
+ if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") {
6177
+ announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
6178
+ goal: activeGoalAfterError,
6179
+ transition: "prompt-failures-paused",
6180
+ reason: activeGoalAfterError.stopReason,
6181
+ expectedState: "paused",
6182
+ expectedStopReason: "auto-continue failures",
6183
+ })
6184
+ }
5142
6185
  }
5143
6186
  await logPluginError(client, "Auto-continue failed", error)
5144
6187
  } finally {
@@ -5357,6 +6400,7 @@ export const testInternals = {
5357
6400
  ledgerPathFor,
5358
6401
  setLedgerSink,
5359
6402
  defaultAuditMessenger,
6403
+ defaultLifecycleMessenger,
5360
6404
  buildAuditPrompt,
5361
6405
  parseAuditVerdict,
5362
6406
  createChildSessionAuditor,
@@ -5375,6 +6419,7 @@ export const testInternals = {
5375
6419
  extractCompletionEvidence,
5376
6420
  findLatestAssistantMessage,
5377
6421
  formatArgumentErrors,
6422
+ goalDisplayState,
5378
6423
  formatStatus,
5379
6424
  getSessionID,
5380
6425
  goalIsBlocked,