opencode-goal-plugin 0.6.7 → 0.7.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.
@@ -19,7 +19,10 @@ import { createOpenCodeSessionApi } from "./opencode-session-api.js"
19
19
  import { applyNativeGoalConfig } from "./native-agent-config.js"
20
20
  import { serializeCompletionClaim } from "./completion-claim.js"
21
21
  import { goalToolFailure, goalToolSuccess, serializeGoalToolResult } from "./goal-tool-result.js"
22
- import { acquirePersistenceLease } from "./persistence-lease.js"
22
+ import {
23
+ acquirePersistenceLease,
24
+ isPersistenceLeaseContendedError,
25
+ } from "./persistence-lease.js"
23
26
 
24
27
  const STATE_FILE_VERSION = 1
25
28
  // Default state now follows the project: <cwd>/.opencode/goals/state.json.
@@ -56,6 +59,11 @@ const DEFAULT_LEDGER_RETENTION_FILES = 3
56
59
  const MAX_LEDGER_LINE_BYTES = 16 * 1024
57
60
  const MIGRATION_LEASE_RETRIES = 200
58
61
  const MIGRATION_LEASE_DELAY_MS = 25
62
+ const PASSIVE_SESSION_RETRY_MS = 250
63
+ const SESSION_OWNED_ELSEWHERE = "session_owned_elsewhere"
64
+ const ACTIVE_PERSISTENCE_DISABLED = Object.freeze({ kind: "active", persistence: "disabled" })
65
+ const ACTIVE_PERSISTENCE_OWNED = Object.freeze({ kind: "active", persistence: "owned" })
66
+ const PLUGIN_DISPOSED = Object.freeze({ kind: "disposed" })
59
67
 
60
68
  const DEFAULT_OPTIONS = {
61
69
  maxTurns: 10,
@@ -87,6 +95,7 @@ function createRuntimeState() {
87
95
  sessionArchive: new Map(),
88
96
  sessionOrdered: new Set(),
89
97
  lastGoalResults: new Map(),
98
+ sessionMutationVersions: new Map(),
90
99
  seenTokens: new Map(),
91
100
  seenUsage: new Map(),
92
101
  seenOutputTokens: new Map(),
@@ -104,6 +113,7 @@ function createRuntimeState() {
104
113
  ledgerSink: null,
105
114
  sessionPersistence: new Map(),
106
115
  sessionLoadPromises: new Map(),
116
+ passiveSessions: new Map(),
107
117
  disposed: false,
108
118
  }
109
119
  }
@@ -115,6 +125,19 @@ function currentRuntime() {
115
125
  return runtimeStorage.getStore() || lastRuntime
116
126
  }
117
127
 
128
+ function runtimeSessionDiagnostics(sessionID) {
129
+ const runtime = currentRuntime()
130
+ return Object.freeze({
131
+ disposed: runtime.disposed,
132
+ loadInFlight: runtime.sessionLoadPromises.has(sessionID),
133
+ persistenceOwned: runtime.sessionPersistence.has(sessionID),
134
+ passive: runtime.passiveSessions.has(sessionID),
135
+ suppressedAssistantCount: [...runtime.suppressedCommandAssistants.values()]
136
+ .filter((ownerSessionID) => ownerSessionID === sessionID)
137
+ .length,
138
+ })
139
+ }
140
+
118
141
  // Route the existing domain helpers to the plugin instance associated with the
119
142
  // current async hook/tool execution. OpenCode caches imported plugin modules but
120
143
  // initializes their factories per workspace, so module-global Maps would let a
@@ -143,6 +166,7 @@ const sessionArchive = runtimeCollection("sessionArchive")
143
166
  const sessionOrdered = runtimeCollection("sessionOrdered")
144
167
  const MAX_ARCHIVED_PER_SESSION = 10
145
168
  const lastGoalResults = runtimeCollection("lastGoalResults")
169
+ const sessionMutationVersions = runtimeCollection("sessionMutationVersions")
146
170
  const seenTokens = runtimeCollection("seenTokens")
147
171
  const seenUsage = runtimeCollection("seenUsage")
148
172
  const seenOutputTokens = runtimeCollection("seenOutputTokens")
@@ -343,6 +367,24 @@ function normalizeExecutionContext(value) {
343
367
  }
344
368
  }
345
369
 
370
+ function rememberSessionExecutionContext(sessionID, value, { replace = false } = {}) {
371
+ if (!sessionID) return null
372
+ const observed = normalizeExecutionContext(value)
373
+ if (!observed) return null
374
+ const runtime = currentRuntime()
375
+ if (replace) {
376
+ runtime.sessionExecutionContexts.set(sessionID, observed)
377
+ return observed
378
+ }
379
+ const previous = normalizeExecutionContext(runtime.sessionExecutionContexts.get(sessionID)) || {}
380
+ const merged = {
381
+ ...previous,
382
+ ...observed,
383
+ }
384
+ runtime.sessionExecutionContexts.set(sessionID, merged)
385
+ return merged
386
+ }
387
+
346
388
  function continuationContextInput(goal) {
347
389
  const context = normalizeExecutionContext(goal?.executionContext)
348
390
  return context ? { ...context } : {}
@@ -450,6 +492,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
450
492
  options: goal.options,
451
493
  stopped: goal.stopped,
452
494
  stopReason: goal.stopReason,
495
+ blockedReason: goal.blockedReason,
453
496
  ordered: sessionOrdered.has(goal.sessionID),
454
497
  },
455
498
  type,
@@ -464,6 +507,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
464
507
  function pushHistory(goal, type, detail, timestamp = Date.now()) {
465
508
  const entry = makeHistoryEntry(type, detail, timestamp)
466
509
  goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES)
510
+ markSessionMutation(goal.sessionID)
467
511
  return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp)
468
512
  }
469
513
 
@@ -598,6 +642,7 @@ function reconstructGoalsFromLedger(entries) {
598
642
  const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim()
599
643
  if (!condition) continue
600
644
  const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {}
645
+ const latestBlocked = [...events].reverse().find((event) => event.type === "blocked")
601
646
 
602
647
  const history = events
603
648
  .map((event) =>
@@ -619,6 +664,12 @@ function reconstructGoalsFromLedger(entries) {
619
664
  options: isPlainObject(snapshot.options) ? snapshot.options : {},
620
665
  stopped: snapshot.stopped === true,
621
666
  stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "",
667
+ blockedReason:
668
+ typeof snapshot.blockedReason === "string"
669
+ ? snapshot.blockedReason
670
+ : snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string"
671
+ ? latestBlocked.detail
672
+ : "",
622
673
  ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))),
623
674
  startedAt: normalizeTimestamp(events[0]?.ts),
624
675
  history,
@@ -635,9 +686,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) {
635
686
  const checkpoint = { summary, timestamp }
636
687
  goal.lastCheckpoint = checkpoint
637
688
  goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
689
+ markSessionMutation(goal.sessionID)
690
+ }
691
+
692
+ function goalDisplayState(goal) {
693
+ if (!goal?.stopped) return "active"
694
+ return goal.stopReason === "blocked" ? "blocked" : "paused"
638
695
  }
639
696
 
640
- function formatStatus(goal, commandName = "goal") {
697
+ function formatStatus(
698
+ goal,
699
+ commandName = "goal",
700
+ completionAuditLabel = "evidence gate only (independent verifier off)",
701
+ ) {
641
702
  const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
642
703
  const lastProgress =
643
704
  goal.lastProgressAt > 0
@@ -648,6 +709,8 @@ function formatStatus(goal, commandName = "goal") {
648
709
  : "none yet"
649
710
  const lines = [
650
711
  `Active goal: ${goal.condition}`,
712
+ `State: ${goalDisplayState(goal)}`,
713
+ `Completion audit: ${completionAuditLabel}`,
651
714
  ]
652
715
  if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`)
653
716
  if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`)
@@ -732,8 +795,16 @@ function sessionGoalMap(sessionID) {
732
795
  return map
733
796
  }
734
797
 
798
+ function markSessionMutation(sessionID) {
799
+ if (!sessionID) return 0
800
+ const next = (sessionMutationVersions.get(sessionID) || 0) + 1
801
+ sessionMutationVersions.set(sessionID, next)
802
+ return next
803
+ }
804
+
735
805
  function registerSessionGoal(goal) {
736
806
  sessionGoalMap(goal.sessionID).set(goal.goalId, goal)
807
+ markSessionMutation(goal.sessionID)
737
808
  }
738
809
 
739
810
  function listSessionGoals(sessionID) {
@@ -756,12 +827,13 @@ function setBoundedMessageValue(map, messageID, value) {
756
827
  function removeSessionGoal(sessionID, goalId) {
757
828
  const map = sessionGoals.get(sessionID)
758
829
  if (!map) return
759
- map.delete(goalId)
830
+ if (map.delete(goalId)) markSessionMutation(sessionID)
760
831
  if (map.size === 0) sessionGoals.delete(sessionID)
761
832
  }
762
833
 
763
834
  function focusGoal(sessionID, goal) {
764
835
  goalStates.set(sessionID, goal)
836
+ markSessionMutation(sessionID)
765
837
  }
766
838
 
767
839
  function pauseGoalClock(goal, timestamp = Date.now()) {
@@ -818,6 +890,10 @@ function cleanupGoal(sessionID) {
818
890
  }
819
891
  goalStates.delete(sessionID)
820
892
  activeContinues.delete(sessionID)
893
+ // Increment even when no focused goal remains. A concurrent clear of a
894
+ // provisional completion is otherwise indistinguishable from unrelated
895
+ // global result-retention pruning while its terminal write is in flight.
896
+ markSessionMutation(sessionID)
821
897
  }
822
898
 
823
899
  function clearRuntimeState() {
@@ -828,6 +904,7 @@ function clearRuntimeState() {
828
904
  sessionArchive.clear()
829
905
  sessionOrdered.clear()
830
906
  lastGoalResults.clear()
907
+ sessionMutationVersions.clear()
831
908
  seenTokens.clear()
832
909
  seenUsage.clear()
833
910
  seenOutputTokens.clear()
@@ -841,9 +918,13 @@ function clearRuntimeState() {
841
918
  runtime.activeCommandTurns.clear()
842
919
  runtime.ownedPluginMessages.clear()
843
920
  runtime.suppressedCommandAssistants.clear()
921
+ runtime.passiveSessions.clear()
844
922
  }
845
923
 
846
- function clearSessionRuntimeState(sessionID) {
924
+ function clearSessionRuntimeState(
925
+ sessionID,
926
+ { preserveCommandSecurity = false, preserveExecutionContext = false } = {},
927
+ ) {
847
928
  const runtime = currentRuntime()
848
929
  for (const goal of sessionGoals.get(sessionID)?.values() || []) {
849
930
  for (const messageID of goal.messageIDs || []) {
@@ -862,14 +943,18 @@ function clearSessionRuntimeState(sessionID) {
862
943
  runtime.continuationControllers.delete(sessionID)
863
944
  runtime.promptInFlightSessions.delete(sessionID)
864
945
  runtime.sessionStatuses.delete(sessionID)
865
- runtime.sessionExecutionContexts.delete(sessionID)
866
- runtime.pendingCommandTurns.delete(sessionID)
867
- runtime.activeCommandTurns.delete(sessionID)
868
- for (const [messageID, owner] of runtime.ownedPluginMessages) {
869
- if (owner?.sessionID === sessionID) runtime.ownedPluginMessages.delete(messageID)
870
- }
871
- for (const [messageID, ownerSessionID] of runtime.suppressedCommandAssistants) {
872
- if (ownerSessionID === sessionID) runtime.suppressedCommandAssistants.delete(messageID)
946
+ if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
947
+ runtime.passiveSessions.delete(sessionID)
948
+ markSessionMutation(sessionID)
949
+ if (!preserveCommandSecurity) {
950
+ runtime.pendingCommandTurns.delete(sessionID)
951
+ runtime.activeCommandTurns.delete(sessionID)
952
+ for (const [messageID, owner] of runtime.ownedPluginMessages) {
953
+ if (owner?.sessionID === sessionID) runtime.ownedPluginMessages.delete(messageID)
954
+ }
955
+ for (const [messageID, ownerSessionID] of runtime.suppressedCommandAssistants) {
956
+ if (ownerSessionID === sessionID) runtime.suppressedCommandAssistants.delete(messageID)
957
+ }
873
958
  }
874
959
  }
875
960
 
@@ -919,16 +1004,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
919
1004
  lastGoalResults.delete(sessionID)
920
1005
  lastGoalResults.set(sessionID, result)
921
1006
  // Keep a per-session archive so completed goals stay readable via /goal list.
922
- archiveSessionResult(sessionID, { ...result })
1007
+ const archivedResult = { ...result }
1008
+ archiveSessionResult(sessionID, archivedResult)
923
1009
  pruneGoalResults(goal.options)
1010
+ markSessionMutation(sessionID)
1011
+ return { lastResult: result, archivedResult }
924
1012
  }
925
1013
 
926
- function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = false } = {}) {
927
- lastGoalResults.delete(sessionID)
1014
+ function captureFocusedGoalSnapshot(sessionID) {
1015
+ const goal = goalStates.get(sessionID) || null
1016
+ return {
1017
+ goal,
1018
+ serialized: goal ? JSON.stringify(serializeGoal(goal)) : "",
1019
+ mutationVersion: sessionMutationVersions.get(sessionID) || 0,
1020
+ }
1021
+ }
1022
+
1023
+ function focusedGoalSnapshotIsCurrent(sessionID, snapshot) {
1024
+ const current = goalStates.get(sessionID) || null
1025
+ if (current !== snapshot?.goal) return false
1026
+ if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false
1027
+ return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized
1028
+ }
1029
+
1030
+ function restoreAfterTerminalPersistenceFailure(
1031
+ sessionID,
1032
+ goal,
1033
+ { ordered = false, expectedCurrentSnapshot, expectedResult } = {},
1034
+ ) {
1035
+ // A terminal write can yield while another command replaces, edits, pauses,
1036
+ // resumes, clears, or advances the session. Never roll the old goal back over
1037
+ // that newer state. The per-session mutation version catches a concurrent
1038
+ // clear even when both the expected and current focused goal are null, while
1039
+ // remaining unaffected by result-retention pruning in a different session.
1040
+ const expectedLastResult = expectedResult?.lastResult || expectedResult
1041
+ const expectedArchivedResult = expectedResult?.archivedResult
1042
+ const canRestore =
1043
+ !expectedCurrentSnapshot ||
1044
+ focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot)
1045
+
1046
+ // Remove only this failed provisional completion record. A newer concurrent
1047
+ // result/archive entry belongs to the newer operation and must survive.
1048
+ if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) {
1049
+ lastGoalResults.delete(sessionID)
1050
+ }
928
1051
  const archived = sessionArchive.get(sessionID) || []
929
- if (archived.length) {
1052
+ if (expectedArchivedResult) {
1053
+ const retained = archived.filter((entry) => entry !== expectedArchivedResult)
1054
+ if (retained.length) sessionArchive.set(sessionID, retained)
1055
+ else sessionArchive.delete(sessionID)
1056
+ } else if (archived.length) {
930
1057
  sessionArchive.set(sessionID, archived.slice(0, -1))
931
1058
  }
1059
+
1060
+ if (!canRestore) return false
932
1061
  const prematurelyPromoted = goalStates.get(sessionID)
933
1062
  if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) {
934
1063
  prematurelyPromoted.stopped = true
@@ -943,6 +1072,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal
943
1072
  goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry."
944
1073
  registerSessionGoal(goal)
945
1074
  focusGoal(sessionID, goal)
1075
+ return true
946
1076
  }
947
1077
 
948
1078
  function resetGoalBudget(goal) {
@@ -1441,7 +1571,12 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1441
1571
  )
1442
1572
  }
1443
1573
 
1444
- if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1574
+ if (onlySessionID) {
1575
+ clearSessionRuntimeState(onlySessionID, {
1576
+ preserveCommandSecurity: true,
1577
+ preserveExecutionContext: true,
1578
+ })
1579
+ }
1445
1580
  else clearRuntimeState()
1446
1581
 
1447
1582
  const focusBySession = new Map()
@@ -1491,15 +1626,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1491
1626
  }
1492
1627
 
1493
1628
  // After applyParsedStateFile loads goals into goalStates, check the ledger for
1494
- // terminal events. If a goal has a "completed" or "cleared" entry in the ledger
1495
- // but still appears active in the state file (because the state write failed
1496
- // after the terminal ledger write), remove it so it is not re-driven.
1629
+ // state transitions that landed after the snapshot. Completed/cleared goals are
1630
+ // removed so they cannot be re-driven, while a newer blocked event is overlaid
1631
+ // so its state and concrete reason survive a failed snapshot write.
1497
1632
  async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
1498
1633
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1499
1634
  maxBytes: persistenceOptions.ledgerMaxBytes,
1500
1635
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
1501
1636
  })
1502
- if (!entries.length) return
1637
+ if (!entries.length) return { removed: 0, blocked: 0 }
1503
1638
 
1504
1639
  const terminalGoals = new Set()
1505
1640
  for (const entry of entries) {
@@ -1512,16 +1647,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
1512
1647
  terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
1513
1648
  }
1514
1649
  }
1515
- if (!terminalGoals.size) return
1516
-
1517
1650
  let removed = 0
1651
+ let blocked = 0
1518
1652
  for (const [sessionID, goals] of sessionGoals.entries()) {
1519
1653
  if (onlySessionID && sessionID !== onlySessionID) continue
1520
1654
  for (const goal of [...goals.values()]) {
1521
- if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
1522
- removeSessionGoal(sessionID, goal.goalId)
1523
- if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
1524
- removed += 1
1655
+ const key = `${sessionID}\0${goal.goalId}`
1656
+ if (terminalGoals.has(key)) {
1657
+ removeSessionGoal(sessionID, goal.goalId)
1658
+ if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
1659
+ removed += 1
1660
+ continue
1661
+ }
1662
+
1663
+ const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered")
1664
+ const latestPersistedTimestamp = persistedHistory.reduce(
1665
+ (latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)),
1666
+ 0,
1667
+ )
1668
+ let latestLedgerState = null
1669
+ let latestLedgerTimestamp = -1
1670
+ for (const entry of entries) {
1671
+ if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue
1672
+ const timestamp = normalizeTimestamp(entry.ts, 0)
1673
+ if (timestamp < latestPersistedTimestamp) continue
1674
+ const detail = summarizeText(entry.detail, 400)
1675
+ const alreadyApplied = persistedHistory.some(
1676
+ (event) =>
1677
+ event.type === entry.type &&
1678
+ normalizeTimestamp(event.timestamp, 0) === timestamp &&
1679
+ event.detail === detail,
1680
+ )
1681
+ if (timestamp >= latestLedgerTimestamp) {
1682
+ latestLedgerState = { entry, alreadyApplied }
1683
+ latestLedgerTimestamp = timestamp
1684
+ }
1685
+ }
1686
+ if (
1687
+ latestLedgerState?.alreadyApplied ||
1688
+ latestLedgerState?.entry?.type !== "blocked" ||
1689
+ latestLedgerState.entry.snapshot?.stopped !== true ||
1690
+ latestLedgerState.entry.snapshot?.stopReason !== "blocked"
1691
+ ) continue
1692
+
1693
+ const reason = summarizeText(
1694
+ latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail,
1695
+ MAX_GOAL_BLOCKER_LENGTH,
1696
+ )
1697
+ if (!reason) continue
1698
+ goal.stopped = true
1699
+ goal.stopReason = "blocked"
1700
+ goal.blockedReason = reason
1701
+ goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind."
1702
+ goal.continuationClaim = null
1703
+ goal.history = [
1704
+ ...(goal.history || []),
1705
+ makeHistoryEntry(
1706
+ "blocked",
1707
+ reason,
1708
+ normalizeTimestamp(latestLedgerState.entry.ts),
1709
+ ),
1710
+ ].slice(-MAX_HISTORY_ENTRIES)
1711
+ pauseGoalClock(goal)
1712
+ blocked += 1
1525
1713
  }
1526
1714
  if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) {
1527
1715
  promoteNextOrderedGoal(sessionID)
@@ -1533,6 +1721,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
1533
1721
  `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).`,
1534
1722
  )
1535
1723
  }
1724
+ if (blocked > 0) {
1725
+ await logPluginError(
1726
+ client,
1727
+ `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).`,
1728
+ )
1729
+ }
1730
+ return { removed, blocked }
1536
1731
  }
1537
1732
 
1538
1733
  async function pathExists(path) {
@@ -1552,7 +1747,7 @@ async function acquireMigrationLease(stateFilePath, migrationMarkerPath) {
1552
1747
  try {
1553
1748
  return await acquirePersistenceLease(stateFilePath)
1554
1749
  } catch (error) {
1555
- if (!String(error?.message || error).includes("goal persistence is already owned")) throw error
1750
+ if (!isPersistenceLeaseContendedError(error)) throw error
1556
1751
  lastError = error
1557
1752
  await new Promise((resolve) => setTimeout(resolve, MIGRATION_LEASE_DELAY_MS))
1558
1753
  }
@@ -1682,6 +1877,7 @@ async function migrateLegacyState(persistenceOptions, client) {
1682
1877
  )
1683
1878
  if (!migrationLease) return
1684
1879
  try {
1880
+ if (currentRuntime().disposed) return
1685
1881
  if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1686
1882
 
1687
1883
  const state = await readPersistedStateFile(candidate.stateFilePath, client)
@@ -1735,16 +1931,30 @@ async function migrateLegacyState(persistenceOptions, client) {
1735
1931
  }
1736
1932
 
1737
1933
  // A fresh project has no aggregate or legacy state. Mark the namespace so a
1738
- // later session does not repeatedly probe global fallback paths.
1739
- await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1934
+ // later session does not repeatedly probe global fallback paths. Separate
1935
+ // session processes must still serialize this shared marker: POSIX rename
1936
+ // replaces an existing destination, while Windows can reject that race.
1937
+ if (currentRuntime().disposed) return
1938
+ const freshMigrationLease = await acquireMigrationLease(
1939
+ persistenceOptions.stateFilePath,
1940
+ persistenceOptions.migrationMarkerPath,
1941
+ )
1942
+ if (!freshMigrationLease) return
1943
+ try {
1944
+ if (currentRuntime().disposed) return
1945
+ if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1946
+ await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1947
+ } finally {
1948
+ await freshMigrationLease.release()
1949
+ }
1740
1950
  }
1741
1951
 
1742
1952
  async function loadPersistedSessionState(persistence, client, sessionID) {
1743
1953
  const state = await readPersistedStateFile(persistence.stateFilePath, client)
1744
1954
  if (state.status === "loaded") {
1745
1955
  await applyParsedStateFile(state.raw, client, sessionID)
1746
- await reconcileLoadedStateWithLedger(persistence, client, sessionID)
1747
- return "loaded"
1956
+ const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID)
1957
+ return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded"
1748
1958
  }
1749
1959
  const recovered = await reconstructFromLedger(persistence, client, sessionID)
1750
1960
  if (state.status === "invalid" && recovered === "reconstructed") {
@@ -1777,7 +1987,12 @@ async function reconstructFromLedger(persistenceOptions, client, onlySessionID =
1777
1987
  )
1778
1988
  if (!reconstructed.length) return "missing"
1779
1989
 
1780
- if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1990
+ if (onlySessionID) {
1991
+ clearSessionRuntimeState(onlySessionID, {
1992
+ preserveCommandSecurity: true,
1993
+ preserveExecutionContext: true,
1994
+ })
1995
+ }
1781
1996
  else clearRuntimeState()
1782
1997
  const focusCandidates = new Map()
1783
1998
  for (const stub of reconstructed) {
@@ -1847,24 +2062,46 @@ async function persistState(persistence, client, sessionID) {
1847
2062
  }
1848
2063
  }
1849
2064
 
1850
- async function logPluginError(client, message, error) {
2065
+ function dispatchAdvisoryHostCall(call, onFailure = () => {}) {
2066
+ try {
2067
+ // Host notices are diagnostic only. Start the SDK request immediately,
2068
+ // contain both synchronous and asynchronous failures, and never let a
2069
+ // stalled host promise retain a persistence lease or block goal controls.
2070
+ void Promise.resolve(call()).catch(onFailure)
2071
+ } catch (error) {
2072
+ onFailure(error)
2073
+ }
2074
+ }
2075
+
2076
+ async function logPluginMessage(client, level, message, error) {
2077
+ const fallback = () => {
2078
+ const logger = level === "warn" ? console.warn : console.error
2079
+ logger("[goal-plugin]", message, error || "")
2080
+ }
1851
2081
  if (client?.app?.log) {
1852
- try {
1853
- await client.app.log({
2082
+ return dispatchAdvisoryHostCall(
2083
+ () => client.app.log({
1854
2084
  body: {
1855
2085
  service: "opencode-goal-plugin",
1856
- level: "error",
2086
+ level,
1857
2087
  message,
1858
- extra: { error: error?.message || error?.name || String(error) },
2088
+ ...(error === undefined
2089
+ ? {}
2090
+ : { extra: { error: error?.message || error?.name || String(error) } }),
1859
2091
  },
1860
- })
1861
- return
1862
- } catch {
1863
- // Logging must never poison persistence or leak an acquired lease.
1864
- }
2092
+ }),
2093
+ fallback,
2094
+ )
1865
2095
  }
2096
+ fallback()
2097
+ }
1866
2098
 
1867
- console.error("[goal-plugin]", message, error || "")
2099
+ async function logPluginError(client, message, error) {
2100
+ return logPluginMessage(client, "error", message, error)
2101
+ }
2102
+
2103
+ async function logPluginWarning(client, message) {
2104
+ return logPluginMessage(client, "warn", message)
1868
2105
  }
1869
2106
 
1870
2107
  function parseGoalArguments(args, defaults) {
@@ -2459,7 +2696,14 @@ function pluginMessageMatches(message, kind, correlationID) {
2459
2696
  return Boolean(correlationID) && pluginMessageCorrelationID(message, kind) === correlationID
2460
2697
  }
2461
2698
 
2462
- function rememberOwnedPluginMessage(message, sessionID, kind, correlationID, policy = "") {
2699
+ function rememberOwnedPluginMessage(
2700
+ message,
2701
+ sessionID,
2702
+ kind,
2703
+ correlationID,
2704
+ policy = "",
2705
+ passive = false,
2706
+ ) {
2463
2707
  const id = messageID(message)
2464
2708
  if (!id) return
2465
2709
  setBoundedMessageValue(currentRuntime().ownedPluginMessages, id, {
@@ -2467,9 +2711,34 @@ function rememberOwnedPluginMessage(message, sessionID, kind, correlationID, pol
2467
2711
  kind,
2468
2712
  correlationID,
2469
2713
  ...(policy ? { policy } : {}),
2714
+ ...(passive ? { passive: true } : {}),
2470
2715
  })
2471
2716
  }
2472
2717
 
2718
+ function suppressControlCommandAssistant(message) {
2719
+ const currentMessageID = messageID(message)
2720
+ const currentSessionID = messageSessionID(message)
2721
+ if (!currentMessageID || !currentSessionID) return false
2722
+ const runtime = currentRuntime()
2723
+ const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
2724
+ const isControlCommandAssistant =
2725
+ messageRole(message) === "assistant" &&
2726
+ parentOwner?.kind === "command" &&
2727
+ parentOwner?.policy === "control" &&
2728
+ parentOwner?.sessionID === currentSessionID
2729
+ if (!isControlCommandAssistant) return false
2730
+ // A control command may produce several assistant messages (for example, a
2731
+ // blocked tool-call step followed by a final report). Authenticate each
2732
+ // response through its owned parent user message and suppress it immediately
2733
+ // so later idle processing cannot treat it as goal progress or completion.
2734
+ setBoundedMessageValue(
2735
+ runtime.suppressedCommandAssistants,
2736
+ currentMessageID,
2737
+ currentSessionID,
2738
+ )
2739
+ return parentOwner?.passive === true ? "passive" : "control"
2740
+ }
2741
+
2473
2742
  function isOwnedPluginMessage(message, kind, ownedMessages = currentRuntime().ownedPluginMessages) {
2474
2743
  const id = messageID(message)
2475
2744
  const correlationID = pluginMessageCorrelationID(message, kind)
@@ -2522,17 +2791,27 @@ function isPluginGeneratedMessage(message, ownedMessages = currentRuntime().owne
2522
2791
  )
2523
2792
  }
2524
2793
 
2794
+ function pruneExpiredPendingCommandTurns(sessionID, now = Date.now()) {
2795
+ const runtime = currentRuntime()
2796
+ const pending = runtime.pendingCommandTurns.get(sessionID)
2797
+ if (pending) {
2798
+ for (const [id, turn] of pending) {
2799
+ if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
2800
+ }
2801
+ if (pending.size === 0) runtime.pendingCommandTurns.delete(sessionID)
2802
+ }
2803
+
2804
+ }
2805
+
2525
2806
  function registerPendingCommandTurn(sessionID, output) {
2526
2807
  const runtime = currentRuntime()
2527
2808
  const now = Date.now()
2809
+ pruneExpiredPendingCommandTurns(sessionID, now)
2528
2810
  let pending = runtime.pendingCommandTurns.get(sessionID)
2529
2811
  if (!pending) {
2530
2812
  pending = new Map()
2531
2813
  runtime.pendingCommandTurns.set(sessionID, pending)
2532
2814
  }
2533
- for (const [id, turn] of pending) {
2534
- if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
2535
- }
2536
2815
  while (pending.size >= MAX_PENDING_COMMAND_TURNS_PER_SESSION) {
2537
2816
  pending.delete(pending.keys().next().value)
2538
2817
  }
@@ -2666,6 +2945,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2666
2945
  }
2667
2946
 
2668
2947
  const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
2948
+ const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived."
2949
+ const AGENT_BLOCK_SUCCESS = "Goal marked blocked."
2669
2950
 
2670
2951
  // Programmatic equivalents of the /goal command, exposed to the agent as tools
2671
2952
  // Each handler operates on a session id and mutates
@@ -2674,14 +2955,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
2674
2955
  // result. Goal creation/replacement routes through the multi-goal registry
2675
2956
  // (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
2676
2957
  // path, so tool-created goals persist and are driven by the idle handler.
2677
- function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState = null, completionAuditor = null, commandName = "goal" }) {
2958
+ function buildAgentToolHandlers({
2959
+ defaultGoalOptions,
2960
+ persist,
2961
+ persistTerminalState = null,
2962
+ completionAuditor = null,
2963
+ completionAuditLabel = "evidence gate only (independent verifier off)",
2964
+ announceAudit = async () => {},
2965
+ auditMessagesEnabled = false,
2966
+ announceLifecycle = () => {},
2967
+ commandName = "goal",
2968
+ }) {
2678
2969
  // Use persistTerminalState (which logs on failure) for terminal operations when
2679
2970
  // available; fall back to plain persist for callers that don't wire it up (e.g.
2680
2971
  // tests using buildAgentToolHandlers directly).
2681
2972
  const persistFinal = persistTerminalState || persist
2682
2973
  async function getGoal(sessionID) {
2683
2974
  const goal = goalStates.get(sessionID)
2684
- if (goal) return formatStatus(goal)
2975
+ if (goal) return formatStatus(goal, commandName, completionAuditLabel)
2685
2976
  const lastResult = lastGoalResults.get(sessionID)
2686
2977
  if (lastResult) return formatGoalResult(lastResult)
2687
2978
  return "No active goal."
@@ -2751,12 +3042,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2751
3042
  // Mirror the `/goal <condition>` replace path: discard the focused goal and
2752
3043
  // its saved result, drop any ordered sequence, then register + focus the new
2753
3044
  // goal so it persists and the idle handler drives it.
3045
+ const replacedGoal = goalStates.get(sessionID)
2754
3046
  sessionOrdered.delete(sessionID)
2755
3047
  cleanupGoal(sessionID)
2756
3048
  lastGoalResults.delete(sessionID)
2757
3049
  registerSessionGoal(goal)
2758
3050
  focusGoal(sessionID, goal)
2759
3051
  await persist(sessionID)
3052
+ announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
3053
+ goal,
3054
+ transition: replacedGoal ? "replaced-active" : "active",
3055
+ expectedState: "active",
3056
+ })
2760
3057
  // Escape in the tool result only: goal.condition is stored raw so callers
2761
3058
  // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
2762
3059
  // themselves. Escaping here prevents XML metacharacters in user-supplied
@@ -2784,6 +3081,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2784
3081
  }
2785
3082
 
2786
3083
  const messages = []
3084
+ let lifecycleNotice = null
2787
3085
 
2788
3086
  if (typeof args.objective === "string" && args.objective.trim()) {
2789
3087
  if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
@@ -2802,6 +3100,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2802
3100
  goal.lastStatus = "Goal objective updated."
2803
3101
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
2804
3102
  messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
3103
+ lifecycleNotice = {
3104
+ text: `Goal updated; state remains ${goalDisplayState(goal)}.`,
3105
+ transition: "updated",
3106
+ reason: goalDisplayState(goal),
3107
+ expectedState: goalDisplayState(goal),
3108
+ expectedStopReason: goal.stopped ? goal.stopReason : "",
3109
+ }
2805
3110
  }
2806
3111
 
2807
3112
  if (args.status !== undefined) {
@@ -2814,13 +3119,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2814
3119
  if (!evidence) return "Completion evidence is required before a goal can be archived."
2815
3120
  if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
2816
3121
  return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
3122
+ const auditedGoalID = goal.goalId
3123
+ const auditedRunID = goal.runId
3124
+ if (auditMessagesEnabled) {
3125
+ await announceAudit(
3126
+ sessionID,
3127
+ "Auditing goal completion: checking submitted evidence before archiving.",
3128
+ )
3129
+ const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID)
3130
+ if (!goalAfterAnnouncement) {
3131
+ return "Completion audit finished after the goal changed; completion was not recorded."
3132
+ }
3133
+ goal = goalAfterAnnouncement
3134
+ }
2817
3135
  // If a completion auditor is configured, run it before archiving so the
2818
3136
  // agent tool path has the same integrity gate as the [goal:complete] marker
2819
3137
  // path. Without this, an autonomous agent could bypass the auditor by
2820
3138
  // calling update_goal({status:"complete"}) instead of using the marker.
2821
3139
  if (completionAuditor) {
2822
- const auditedGoalID = goal.goalId
2823
- const auditedRunID = goal.runId
2824
3140
  let verdict
2825
3141
  try {
2826
3142
  verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
@@ -2839,6 +3155,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2839
3155
  goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
2840
3156
  pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
2841
3157
  await persist(sessionID)
3158
+ const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID)
3159
+ if (
3160
+ rejectedGoalAfterPersist !== goal ||
3161
+ !goal.stopped ||
3162
+ goal.stopReason !== "audit rejected"
3163
+ ) {
3164
+ return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched."
3165
+ }
3166
+ if (auditMessagesEnabled) {
3167
+ await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
3168
+ } else {
3169
+ announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
3170
+ goal,
3171
+ transition: "audit-rejected",
3172
+ reason,
3173
+ expectedState: "paused",
3174
+ expectedStopReason: "audit rejected",
3175
+ })
3176
+ }
2842
3177
  return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
2843
3178
  }
2844
3179
  }
@@ -2849,16 +3184,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2849
3184
  evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
2850
3185
  )
2851
3186
  const ordered = sessionOrdered.has(sessionID)
2852
- rememberGoalResult(sessionID, goal, "achieved", "", evidence)
3187
+ const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence)
2853
3188
  cleanupGoal(sessionID)
2854
3189
  // Advance an ordered sequence just like the marker path does.
2855
- if (ordered) promoteNextOrderedGoal(sessionID)
3190
+ const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
3191
+ const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
2856
3192
  const durable = await persistFinal(sessionID, "completion", ledgerDurable)
2857
3193
  if (durable === false) {
2858
- restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
2859
- return "Completion verified, but terminal state could not be persisted. Goal remains paused."
3194
+ const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, {
3195
+ ordered,
3196
+ expectedCurrentSnapshot: postCompletionSnapshot,
3197
+ expectedResult: completedResult,
3198
+ })
3199
+ if (auditMessagesEnabled) {
3200
+ await announceAudit(
3201
+ sessionID,
3202
+ restored
3203
+ ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
3204
+ : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
3205
+ )
3206
+ } else {
3207
+ announceLifecycle(
3208
+ sessionID,
3209
+ restored
3210
+ ? "Goal paused — completion could not be recorded durably."
3211
+ : "Previous goal completion could not be confirmed durably after goal state changed.",
3212
+ restored
3213
+ ? {
3214
+ goal,
3215
+ transition: "terminal-persistence-failed",
3216
+ reason: goal.stopReason,
3217
+ expectedState: "paused",
3218
+ expectedStopReason: "terminal persistence failed",
3219
+ }
3220
+ : {
3221
+ transition: "terminal-persistence-raced",
3222
+ requireCurrent: false,
3223
+ },
3224
+ )
3225
+ }
3226
+ return restored
3227
+ ? "Completion verified, but terminal state could not be persisted. Goal remains paused."
3228
+ : "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched."
2860
3229
  }
2861
- return "Goal marked complete and archived."
3230
+ const activePromoted = promoted
3231
+ ? activeGoal(sessionID, promoted.goalId, promoted.runId)
3232
+ : null
3233
+ if (auditMessagesEnabled) {
3234
+ await announceAudit(
3235
+ sessionID,
3236
+ activePromoted
3237
+ ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
3238
+ : "Audit result: completion accepted — goal archived as achieved.",
3239
+ )
3240
+ } else {
3241
+ announceLifecycle(
3242
+ sessionID,
3243
+ activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
3244
+ {
3245
+ goal: activePromoted || goal,
3246
+ transition: activePromoted ? "achieved-promoted" : "achieved",
3247
+ requireCurrent: Boolean(activePromoted),
3248
+ expectedState: activePromoted ? "active" : "",
3249
+ },
3250
+ )
3251
+ }
3252
+ return AGENT_COMPLETE_SUCCESS
2862
3253
  }
2863
3254
  if (status === "blocked") {
2864
3255
  const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
@@ -2866,18 +3257,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2866
3257
  return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
2867
3258
  if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
2868
3259
  return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
3260
+ const blockedGoalID = goal.goalId
3261
+ const blockedRunID = goal.runId
3262
+ if (auditMessagesEnabled) {
3263
+ await announceAudit(
3264
+ sessionID,
3265
+ "Auditing goal blocker: checking the submitted blocker before pausing.",
3266
+ )
3267
+ const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID)
3268
+ if (!goalAfterAnnouncement) {
3269
+ return "Blocker audit finished after the goal changed; blocked state was not recorded."
3270
+ }
3271
+ goal = goalAfterAnnouncement
3272
+ }
2869
3273
  goal.blockedReason = blockerText
2870
3274
  goal.stopped = true
2871
3275
  goal.stopReason = "blocked"
2872
3276
  goal.lastStatus = "Assistant reported blocked."
2873
- pushHistory(goal, "blocked", goal.blockedReason)
2874
- messages.push("Goal marked blocked.")
3277
+ const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason)
3278
+ messages.push(AGENT_BLOCK_SUCCESS)
3279
+ const durable = await persistFinal(sessionID, "blocked", ledgerDurable)
3280
+ const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID)
3281
+ if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") {
3282
+ return "Blocked state changed while persistence completed; blocked state was not reported."
3283
+ }
3284
+ if (durable === false) {
3285
+ goal.stopReason = "terminal persistence failed"
3286
+ goal.lastStatus = "Blocked state could not be persisted; goal remains paused."
3287
+ if (auditMessagesEnabled) {
3288
+ await announceAudit(
3289
+ sessionID,
3290
+ "Audit result: blocker recognized, but storage failed; goal remains paused.",
3291
+ )
3292
+ } else {
3293
+ announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
3294
+ goal,
3295
+ transition: "terminal-persistence-failed",
3296
+ expectedState: "paused",
3297
+ expectedStopReason: "terminal persistence failed",
3298
+ })
3299
+ }
3300
+ return "Blocker recognized, but terminal state could not be persisted. Goal remains paused."
3301
+ }
3302
+ if (auditMessagesEnabled) {
3303
+ await announceAudit(
3304
+ sessionID,
3305
+ `Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`,
3306
+ )
3307
+ } else {
3308
+ announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
3309
+ goal,
3310
+ transition: "blocked",
3311
+ expectedState: "blocked",
3312
+ expectedStopReason: "blocked",
3313
+ })
3314
+ }
3315
+ return messages.join(" ")
2875
3316
  } else if (status === "paused") {
2876
- goal.stopped = true
2877
- goal.stopReason = "paused"
2878
- goal.lastStatus = "Goal paused."
2879
- pushHistory(goal, "paused", "Paused via agent tool.")
2880
- messages.push("Goal paused.")
3317
+ if (goal.stopped && goal.stopReason === "paused") {
3318
+ if (!messages.length) return "Goal is already paused."
3319
+ messages.push("Goal is already paused.")
3320
+ } else {
3321
+ goal.stopped = true
3322
+ goal.stopReason = "paused"
3323
+ goal.lastStatus = "Goal paused."
3324
+ pushHistory(goal, "paused", "Paused via agent tool.")
3325
+ messages.push("Goal paused.")
3326
+ lifecycleNotice = {
3327
+ text: "Goal paused.",
3328
+ transition: "paused",
3329
+ reason: goal.stopReason,
3330
+ expectedState: "paused",
3331
+ expectedStopReason: "paused",
3332
+ }
3333
+ }
2881
3334
  } else if (status === "resumed") {
2882
3335
  if (!goal.stopped)
2883
3336
  return "Goal is already running. Pause or stop it first if you want to reset the budget window."
@@ -2891,6 +3344,11 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2891
3344
  goal.lastStatus = "Goal resumed with a fresh local budget."
2892
3345
  pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
2893
3346
  messages.push("Goal resumed with fresh limits.")
3347
+ lifecycleNotice = {
3348
+ text: "Goal resumed with fresh limits.",
3349
+ transition: "resumed",
3350
+ expectedState: "active",
3351
+ }
2894
3352
  }
2895
3353
  }
2896
3354
 
@@ -2898,6 +3356,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2898
3356
  return "Nothing to update. Provide `objective` and/or `status`."
2899
3357
  }
2900
3358
  await persist(sessionID)
3359
+ if (lifecycleNotice) {
3360
+ announceLifecycle(sessionID, lifecycleNotice.text, {
3361
+ goal,
3362
+ transition: lifecycleNotice.transition,
3363
+ reason: lifecycleNotice.reason,
3364
+ expectedState: lifecycleNotice.expectedState,
3365
+ expectedStopReason: lifecycleNotice.expectedStopReason,
3366
+ })
3367
+ }
2901
3368
  return messages.join(" ")
2902
3369
  }
2903
3370
 
@@ -2906,15 +3373,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2906
3373
  // focused goal + result. Without sessionGoals.delete, background goals added via
2907
3374
  // `/goal add` survive clear and resurrect as the focused goal on restart.
2908
3375
  // Record the clear in the ledger before cleanupGoal removes the goal object.
2909
- for (const goal of listSessionGoals(sessionID)) {
2910
- pushHistory(goal, "cleared", "Cleared via agent tool.")
2911
- }
3376
+ const goals = listSessionGoals(sessionID)
3377
+ const clearedGoal = goalStates.get(sessionID) || goals[0] || null
3378
+ const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
3379
+ const ledgerDurable =
3380
+ goals.length > 0 &&
3381
+ goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean)
2912
3382
  sessionOrdered.delete(sessionID)
2913
3383
  sessionGoals.delete(sessionID)
2914
3384
  cleanupGoal(sessionID)
2915
3385
  lastGoalResults.delete(sessionID)
2916
- await persistFinal(sessionID, "clear")
2917
- return "Goal cleared."
3386
+ const durable = await persistFinal(sessionID, "clear", ledgerDurable)
3387
+ const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
3388
+ if (hadState && clearStillCurrent) {
3389
+ announceLifecycle(sessionID, durable === false
3390
+ ? "Goal cleared in memory, but storage failed; it may reappear after restart."
3391
+ : "Goal cleared.", {
3392
+ goal: clearedGoal,
3393
+ transition: durable === false ? "clear-persistence-failed" : "cleared",
3394
+ requireCurrent: false,
3395
+ })
3396
+ }
3397
+ if (!clearStillCurrent) {
3398
+ return "Clear persistence finished after goal state changed; current state was left untouched."
3399
+ }
3400
+ return durable === false
3401
+ ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
3402
+ : "Goal cleared."
2918
3403
  }
2919
3404
 
2920
3405
  return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
@@ -2930,12 +3415,70 @@ function agentToolSessionID(ctx) {
2930
3415
  // helper's unrelated SDK/effect dependency graph in every consumer project.
2931
3416
  const bundledToolHelper = Object.assign((definition) => definition, { schema: z })
2932
3417
 
2933
- function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
3418
+ function sessionOwnedElsewhereMessage(
3419
+ commandName = "goal",
3420
+ commandRegistered = true,
3421
+ reason = "owned_elsewhere",
3422
+ ) {
3423
+ const retryTarget = commandRegistered
3424
+ ? `\`/${commandName} status\``
3425
+ : "the `goal_status` tool"
3426
+ if (reason === "legacy_lock") {
3427
+ return (
3428
+ "Goal controls are unavailable because this session has an older or incomplete persistence lease. " +
3429
+ "No goal state was read or changed here. Ordinary chat remains available. " +
3430
+ "Close every OpenCode process using this session and upgrade them first. If the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or open a fork with `opencode --continue --fork`, " +
3431
+ `then retry ${retryTarget}.`
3432
+ )
3433
+ }
3434
+ return (
3435
+ "Goal controls are unavailable in this OpenCode instance because another process owns this session's goal workflow. " +
3436
+ "No goal state was read or changed here. Ordinary chat remains available. " +
3437
+ `Close the owning process or open a fork with \`opencode --continue --fork\`, then retry ${retryTarget}.`
3438
+ )
3439
+ }
3440
+
3441
+ function inactiveGoalToolResult(
3442
+ loadResult,
3443
+ commandName = "goal",
3444
+ disposed = false,
3445
+ commandRegistered = true,
3446
+ ) {
3447
+ if (disposed || loadResult?.kind === "disposed") {
3448
+ return goalToolFailure("plugin_disposed", "The goal plugin is no longer active in this process.")
3449
+ }
3450
+ if (loadResult?.kind === "passive") {
3451
+ return goalToolFailure(
3452
+ SESSION_OWNED_ELSEWHERE,
3453
+ sessionOwnedElsewhereMessage(commandName, commandRegistered, loadResult.reason),
3454
+ )
3455
+ }
3456
+ return null
3457
+ }
3458
+
3459
+ function buildAgentTools(
3460
+ toolHelper,
3461
+ handlers,
3462
+ ensureSessionLoaded = async () => ACTIVE_PERSISTENCE_DISABLED,
3463
+ commandName = "goal",
3464
+ isDisposed = () => false,
3465
+ commandRegistered = true,
3466
+ ) {
2934
3467
  const schema = toolHelper.schema
2935
3468
  const run = (handler) => async (args, ctx) => {
2936
3469
  const sessionID = agentToolSessionID(ctx)
2937
3470
  if (!sessionID) return "No session id available for the goal tool."
2938
- await ensureSessionLoaded(sessionID)
3471
+ const loadResult = await ensureSessionLoaded(sessionID, {
3472
+ retryPassive: true,
3473
+ executionContext: ctx,
3474
+ })
3475
+ const unavailable = inactiveGoalToolResult(
3476
+ loadResult,
3477
+ commandName,
3478
+ isDisposed(),
3479
+ commandRegistered,
3480
+ )
3481
+ if (unavailable) return unavailable.message
2939
3482
  return handler(sessionID, args || {})
2940
3483
  }
2941
3484
  // Canonical tools use a small, versioned machine-readable envelope. Keep the
@@ -2949,7 +3492,17 @@ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () =>
2949
3492
  goalToolFailure("missing_session", "No session id available for the goal tool."),
2950
3493
  )
2951
3494
  }
2952
- await ensureSessionLoaded(sessionID)
3495
+ const loadResult = await ensureSessionLoaded(sessionID, {
3496
+ retryPassive: true,
3497
+ executionContext: ctx,
3498
+ })
3499
+ const unavailable = inactiveGoalToolResult(
3500
+ loadResult,
3501
+ commandName,
3502
+ isDisposed(),
3503
+ commandRegistered,
3504
+ )
3505
+ if (unavailable) return serializeGoalToolResult(operation, unavailable)
2953
3506
  return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
2954
3507
  }
2955
3508
 
@@ -2971,8 +3524,22 @@ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () =>
2971
3524
  return goalToolFailure("already_running", "Goal is already running.")
2972
3525
  }
2973
3526
  const message = await handlers.updateGoal(sessionID, args)
2974
- if (args.status === "complete" && currentGoal(sessionID)) {
2975
- return goalToolFailure("completion_rejected", message)
3527
+ if (args.status === "complete") {
3528
+ if (
3529
+ message !== AGENT_COMPLETE_SUCCESS ||
3530
+ currentGoal(sessionID, before.goalId, before.runId)
3531
+ ) {
3532
+ return goalToolFailure("completion_rejected", message)
3533
+ }
3534
+ }
3535
+ if (args.status === "blocked") {
3536
+ const after = currentGoal(sessionID, before.goalId, before.runId)
3537
+ if (after !== before) {
3538
+ return goalToolFailure("goal_changed", message)
3539
+ }
3540
+ if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") {
3541
+ return goalToolFailure("block_rejected", message)
3542
+ }
2976
3543
  }
2977
3544
  return goalToolSuccess(message)
2978
3545
  },
@@ -3092,8 +3659,14 @@ function formatGoalList(sessionID, commandName = "goal") {
3092
3659
  lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
3093
3660
  goals.forEach((goal, index) => {
3094
3661
  const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
3095
- const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
3096
- lines.push(`${index + 1}. [${marker}] ${goal.condition}${state}`)
3662
+ const state = goalDisplayState(goal)
3663
+ const reason = state === "blocked"
3664
+ ? goal.blockedReason || goal.stopReason
3665
+ : goal.stopped
3666
+ ? goal.stopReason
3667
+ : ""
3668
+ const reasonText = reason ? ` (${summarizeText(reason, 160)})` : ""
3669
+ lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`)
3097
3670
  })
3098
3671
  lines.push(`Switch with \`/${commandName} focus <number>\`.`)
3099
3672
  } else {
@@ -3118,24 +3691,55 @@ function formatGoalList(sessionID, commandName = "goal") {
3118
3691
  // once a non-prompting message API is available.
3119
3692
  async function defaultAuditMessenger(client, sessionID, text) {
3120
3693
  if (client?.app?.log) {
3121
- await client.app.log({
3694
+ dispatchAdvisoryHostCall(() => client.app.log({
3122
3695
  body: {
3123
3696
  service: "opencode-goal-plugin",
3124
3697
  level: "info",
3125
3698
  message: text,
3126
3699
  extra: { sessionID, kind: "goal-audit" },
3127
3700
  },
3128
- })
3701
+ }))
3129
3702
  }
3130
3703
  if (client?.tui?.showToast) {
3131
- await client.tui.showToast({
3704
+ dispatchAdvisoryHostCall(() => client.tui.showToast({
3132
3705
  body: {
3133
3706
  title: "Goal workflow",
3134
3707
  message: summarizeText(text, 500),
3135
3708
  variant: /rejected|failed|blocked/i.test(text) ? "warning" : "info",
3136
3709
  duration: 6000,
3137
3710
  },
3138
- })
3711
+ }))
3712
+ }
3713
+ }
3714
+
3715
+ // High-signal lifecycle feedback uses the same non-blocking host surfaces as
3716
+ // audit notices, but remains a separate channel so callers can configure each
3717
+ // independently. Messages are normalized and bounded before they reach either
3718
+ // host API; goal objectives, evidence, and workspace paths are deliberately
3719
+ // excluded by transition call sites.
3720
+ async function defaultLifecycleMessenger(client, sessionID, text) {
3721
+ const message = summarizeText(text, 500)
3722
+ const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message)
3723
+ const success = /\b(?:achieved|completed)\b/i.test(message)
3724
+ if (client?.app?.log) {
3725
+ dispatchAdvisoryHostCall(() => client.app.log({
3726
+ body: {
3727
+ service: "opencode-goal-plugin",
3728
+ level: warning ? "warn" : "info",
3729
+ message,
3730
+ extra: { sessionID, kind: "goal-lifecycle" },
3731
+ },
3732
+ }))
3733
+ }
3734
+ if (client?.tui?.showToast) {
3735
+ dispatchAdvisoryHostCall(() => client.tui.showToast({
3736
+ body: {
3737
+ title: "Goal workflow",
3738
+ message,
3739
+ variant: warning ? "warning" : success ? "success" : "info",
3740
+ duration: 6000,
3741
+ },
3742
+ }))
3139
3743
  }
3140
3744
  }
3141
3745
 
@@ -3295,11 +3899,104 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3295
3899
  return persistence.persistChain
3296
3900
  }
3297
3901
 
3298
- const ensureSessionLoaded = async (sessionID) => {
3299
- if (!persistenceOptions.persistState || !sessionID) return true
3902
+ const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false
3903
+ const lifecycleMessenger =
3904
+ typeof pluginOptions.lifecycleMessenger === "function"
3905
+ ? pluginOptions.lifecycleMessenger
3906
+ : (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text)
3907
+ const announceLifecycle = (
3908
+ sessionID,
3909
+ text,
3910
+ {
3911
+ goal,
3912
+ transition = "state",
3913
+ reason = "",
3914
+ requireCurrent = true,
3915
+ expectedState = "",
3916
+ expectedStopReason = "",
3917
+ } = {},
3918
+ ) => {
3919
+ if (!lifecycleMessagesEnabled || !sessionID) return false
3920
+ if (requireCurrent && goal) {
3921
+ const current = goalStates.get(sessionID)
3922
+ if (current !== goal) return false
3923
+ if (expectedState && goalDisplayState(current) !== expectedState) return false
3924
+ if (expectedStopReason && current.stopReason !== expectedStopReason) return false
3925
+ }
3926
+ const message = summarizeText(text, 500)
3927
+ if (!message) return false
3928
+ dispatchAdvisoryHostCall(
3929
+ () => lifecycleMessenger(sessionID, message),
3930
+ (error) => {
3931
+ void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {})
3932
+ },
3933
+ )
3934
+ return true
3935
+ }
3936
+
3937
+ const passiveLoadResult = (entry) => ({
3938
+ kind: "passive",
3939
+ code: SESSION_OWNED_ELSEWHERE,
3940
+ reason: entry.reason,
3941
+ owner: entry.owner,
3942
+ retryAt: entry.retryAt,
3943
+ })
3944
+
3945
+ const enterPassiveSession = async (sessionID, error) => {
3946
+ const previous = runtime.passiveSessions.get(sessionID)
3947
+ clearSessionRuntimeState(sessionID, {
3948
+ preserveCommandSecurity: Boolean(previous),
3949
+ preserveExecutionContext: true,
3950
+ })
3951
+ const entry = {
3952
+ code: SESSION_OWNED_ELSEWHERE,
3953
+ reason: error.reason,
3954
+ owner: error.owner,
3955
+ firstObservedAt: previous?.firstObservedAt || Date.now(),
3956
+ retryAt: Date.now() + PASSIVE_SESSION_RETRY_MS,
3957
+ warned: true,
3958
+ }
3959
+ runtime.passiveSessions.set(sessionID, entry)
3960
+ if (!previous?.warned) {
3961
+ const owner = entry.owner?.pid && entry.owner?.hostname
3962
+ ? `pid ${entry.owner.pid} on ${entry.owner.hostname}`
3963
+ : "another process"
3964
+ const warning = entry.reason === "legacy_lock"
3965
+ ? "Goal controls are passive for this session because its persistence lease is from an older release or is incomplete. Ordinary chat remains available. Close every OpenCode process using this session and upgrade them; if the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or fork the session before retrying goal controls."
3966
+ : `Goal controls are passive for this session because ${owner} owns its persistence lease. Ordinary chat remains available; close the owner or fork the session before retrying goal controls.`
3967
+ // Host logging is advisory. A broken or backpressured logger must not
3968
+ // turn passive mode back into the session-wide hang it is meant to
3969
+ // prevent, and the contained rejection avoids an unhandled promise.
3970
+ void logPluginWarning(
3971
+ client,
3972
+ warning,
3973
+ ).catch(() => {})
3974
+ }
3975
+ return passiveLoadResult(entry)
3976
+ }
3977
+
3978
+ const ensureSessionLoaded = async (
3979
+ sessionID,
3980
+ { retryPassive = false, executionContext, freshCommandBoundary = false } = {},
3981
+ ) => {
3982
+ if (runtime.disposed) return PLUGIN_DISPOSED
3983
+ rememberSessionExecutionContext(sessionID, executionContext)
3984
+ if (!persistenceOptions.persistState || !sessionID) return ACTIVE_PERSISTENCE_DISABLED
3300
3985
  const existingLoad = runtime.sessionLoadPromises.get(sessionID)
3301
3986
  if (existingLoad) return existingLoad
3302
- if (runtime.sessionPersistence.has(sessionID)) return true
3987
+ if (runtime.sessionPersistence.has(sessionID)) return ACTIVE_PERSISTENCE_OWNED
3988
+
3989
+ const passive = runtime.passiveSessions.get(sessionID)
3990
+ pruneExpiredPendingCommandTurns(sessionID)
3991
+ const commandTurnInFlight =
3992
+ runtime.pendingCommandTurns.has(sessionID) ||
3993
+ (!freshCommandBoundary && runtime.activeCommandTurns.has(sessionID))
3994
+ if (
3995
+ passive &&
3996
+ (!retryPassive || commandTurnInFlight || Date.now() < passive.retryAt)
3997
+ ) {
3998
+ return passiveLoadResult(passive)
3999
+ }
3303
4000
 
3304
4001
  const load = (async () => {
3305
4002
  const paths = sessionPathsFor(persistenceOptions, sessionID)
@@ -3307,20 +4004,69 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3307
4004
  ...persistenceOptions,
3308
4005
  stateFilePath: paths.stateFilePath,
3309
4006
  })
3310
- const lease = await acquirePersistenceLease(paths.stateFilePath)
4007
+ let lease
4008
+ try {
4009
+ lease = await acquirePersistenceLease(paths.stateFilePath)
4010
+ } catch (error) {
4011
+ if (!isPersistenceLeaseContendedError(error)) throw error
4012
+ return enterPassiveSession(sessionID, error)
4013
+ }
4014
+ const releaseDisposedSession = async () => {
4015
+ runtime.sessionPersistence.delete(sessionID)
4016
+ await lease.release().catch(() => false)
4017
+ return PLUGIN_DISPOSED
4018
+ }
4019
+ if (runtime.disposed) return releaseDisposedSession()
3311
4020
  const persistence = {
3312
4021
  ...persistenceOptions,
3313
4022
  ...paths,
3314
4023
  persistChain: Promise.resolve(true),
3315
4024
  lease,
3316
4025
  }
4026
+ runtime.passiveSessions.delete(sessionID)
3317
4027
  runtime.sessionPersistence.set(sessionID, persistence)
3318
4028
  try {
3319
4029
  await migrateLegacyState(persistenceOptions, client)
4030
+ if (runtime.disposed) return releaseDisposedSession()
3320
4031
  const status = await loadPersistedSessionState(persistence, client, sessionID)
4032
+ if (runtime.disposed) return releaseDisposedSession()
3321
4033
  pruneGoalResults(defaultGoalOptions)
3322
- if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID)
3323
- return true
4034
+ if (
4035
+ status === "loaded" ||
4036
+ status === "missing" ||
4037
+ status === "reconstructed" ||
4038
+ status === "reconciled-blocked"
4039
+ ) await persist(sessionID)
4040
+ const recoveredGoal = goalStates.get(sessionID)
4041
+ if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") {
4042
+ announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, {
4043
+ goal: recoveredGoal,
4044
+ transition: "recovered-paused",
4045
+ reason: recoveredGoal.stopReason,
4046
+ expectedState: "paused",
4047
+ expectedStopReason: "recovered after restart",
4048
+ })
4049
+ } else if (
4050
+ status === "reconciled-blocked" &&
4051
+ recoveredGoal?.stopped &&
4052
+ recoveredGoal.stopReason === "blocked"
4053
+ ) {
4054
+ announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, {
4055
+ goal: recoveredGoal,
4056
+ transition: "recovered-blocked",
4057
+ reason: recoveredGoal.blockedReason,
4058
+ expectedState: "blocked",
4059
+ expectedStopReason: "blocked",
4060
+ })
4061
+ } else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") {
4062
+ announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", {
4063
+ goal: recoveredGoal,
4064
+ transition: "recovered-promoted",
4065
+ expectedState: "active",
4066
+ })
4067
+ }
4068
+ if (runtime.disposed) return releaseDisposedSession()
4069
+ return ACTIVE_PERSISTENCE_OWNED
3324
4070
  } catch (error) {
3325
4071
  runtime.sessionPersistence.delete(sessionID)
3326
4072
  await lease.release().catch(() => false)
@@ -3403,6 +4149,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3403
4149
  reason: "owned verifier agent registration was not confirmed",
3404
4150
  })
3405
4151
  : null
4152
+ const completionAuditLabel =
4153
+ typeof pluginOptions.auditor === "function"
4154
+ ? "custom completion auditor"
4155
+ : pluginOptions.completionAudit
4156
+ ? "built-in independent verifier"
4157
+ : "evidence gate only (independent verifier off)"
3406
4158
 
3407
4159
  clearRuntimeState()
3408
4160
 
@@ -3411,6 +4163,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3411
4163
  persist,
3412
4164
  persistTerminalState,
3413
4165
  completionAuditor,
4166
+ completionAuditLabel,
4167
+ announceAudit,
4168
+ auditMessagesEnabled,
4169
+ announceLifecycle,
3414
4170
  commandName,
3415
4171
  })
3416
4172
 
@@ -3436,6 +4192,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3436
4192
  ) => {
3437
4193
  const goal = goalStates.get(sessionID)
3438
4194
  if (!goal) return false
4195
+ if (goal.stopped && goal.stopReason === reason) return false
3439
4196
  currentRuntime().continuationControllers.get(sessionID)?.abort()
3440
4197
  goal.stopped = true
3441
4198
  goal.stopReason = reason
@@ -3444,6 +4201,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3444
4201
  pushHistory(goal, "paused", history)
3445
4202
  activeContinues.delete(sessionID)
3446
4203
  await persist(sessionID)
4204
+ announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, {
4205
+ goal,
4206
+ transition: "paused",
4207
+ reason,
4208
+ expectedState: "paused",
4209
+ expectedStopReason: reason,
4210
+ })
3447
4211
  if (abortAccepted) await abortAcceptedContinuation(sessionID)
3448
4212
  return true
3449
4213
  }
@@ -3520,11 +4284,54 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3520
4284
  goal.stopReason = "continuation claim persistence failed"
3521
4285
  goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
3522
4286
  pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
4287
+ announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", {
4288
+ goal,
4289
+ transition: "continuation-persistence-failed",
4290
+ reason: goal.stopReason,
4291
+ expectedState: "paused",
4292
+ expectedStopReason: "continuation claim persistence failed",
4293
+ })
3523
4294
  return null
3524
4295
  }
3525
4296
  return goal
3526
4297
  }
3527
4298
 
4299
+ const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => {
4300
+ const runtime = currentRuntime()
4301
+ const activeCommandTurn = runtime.activeCommandTurns.get(sessionID)
4302
+ if (!activeCommandTurn) return { ready: true, messages: null }
4303
+
4304
+ const commandHostMessages = await sessionApi.messages(sessionID, {
4305
+ limit: messageLimit,
4306
+ })
4307
+ if (runtime.disposed) return { ready: false, messages: null }
4308
+ const commandMessages = Array.isArray(commandHostMessages)
4309
+ ? commandHostMessages.slice(-messageLimit)
4310
+ : []
4311
+ if (runtime.activeCommandTurns.get(sessionID) !== activeCommandTurn) {
4312
+ return { ready: false, messages: commandMessages }
4313
+ }
4314
+ const commandAssistant = findLatestAssistantMessage(commandMessages)
4315
+ if (
4316
+ !commandAssistant ||
4317
+ messageParentID(commandAssistant) !== activeCommandTurn.messageID
4318
+ ) {
4319
+ return { ready: false, messages: commandMessages }
4320
+ }
4321
+ if (activeCommandTurn.policy === "control") {
4322
+ const commandAssistantID = messageID(commandAssistant)
4323
+ if (commandAssistantID) {
4324
+ setBoundedMessageValue(
4325
+ runtime.suppressedCommandAssistants,
4326
+ commandAssistantID,
4327
+ sessionID,
4328
+ )
4329
+ }
4330
+ }
4331
+ runtime.activeCommandTurns.delete(sessionID)
4332
+ return { ready: true, messages: commandMessages }
4333
+ }
4334
+
3528
4335
  const hooks = {
3529
4336
  config: async (config) => {
3530
4337
  applyNativeGoalConfig(config, {
@@ -3535,20 +4342,29 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3535
4342
  },
3536
4343
  "chat.params": async (input) => {
3537
4344
  if (!input?.sessionID) return
3538
- await ensureSessionLoaded(input.sessionID)
3539
- const context = normalizeExecutionContext({
3540
- agent: input.agent,
3541
- model: input.model,
3542
- variant: input?.message?.model?.variant,
4345
+ const loadResult = await ensureSessionLoaded(input.sessionID, {
4346
+ executionContext: input,
3543
4347
  })
3544
- if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
4348
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4349
+ rememberSessionExecutionContext(
4350
+ input.sessionID,
4351
+ {
4352
+ agent: input.agent,
4353
+ model: input.model,
4354
+ variant:
4355
+ input.variant ?? input?.model?.variant ?? input?.message?.model?.variant,
4356
+ },
4357
+ { replace: true },
4358
+ )
3545
4359
  },
3546
4360
  "chat.message": async (input, output) => {
3547
4361
  const sessionID = input?.sessionID
3548
4362
  if (!sessionID) return
3549
- await ensureSessionLoaded(sessionID)
3550
- const context = normalizeExecutionContext(input)
3551
- if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
4363
+ const loadResult = await ensureSessionLoaded(sessionID, {
4364
+ executionContext: input,
4365
+ })
4366
+ if (currentRuntime().disposed) return
4367
+ rememberSessionExecutionContext(sessionID, input, { replace: true })
3552
4368
 
3553
4369
  const message = {
3554
4370
  info: isPlainObject(output?.message)
@@ -3581,6 +4397,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3581
4397
  "command",
3582
4398
  commandTurn.id,
3583
4399
  commandTurn.policy,
4400
+ commandTurn.passive === true,
3584
4401
  )
3585
4402
  return
3586
4403
  }
@@ -3590,6 +4407,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3590
4407
  // in flight; public synthetic/metadata fields alone are never trusted.
3591
4408
  runtime.pendingCommandTurns.delete(sessionID)
3592
4409
  runtime.activeCommandTurns.delete(sessionID)
4410
+ if (loadResult.kind !== "active") return
3593
4411
  const continuationID = activeContinues.get(sessionID)
3594
4412
  if (
3595
4413
  currentMessageID &&
@@ -3615,6 +4433,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3615
4433
  const sessionID = input?.sessionID
3616
4434
  if (!sessionID) return
3617
4435
  await ensureSessionLoaded(sessionID)
4436
+ if (currentRuntime().disposed) return
3618
4437
  if (currentRuntime().activeCommandTurns.get(sessionID)?.policy !== "control") return
3619
4438
  throw new Error(
3620
4439
  `This /${commandName} control command has already been handled. Tool "${input?.tool || "unknown"}" was blocked because no tool calls are allowed while its result is being reported. Wait for a separate user turn before using tools or modifying work or goal state.`,
@@ -3625,8 +4444,26 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3625
4444
 
3626
4445
  const sessionID = input.sessionID
3627
4446
  if (!sessionID) return
3628
- await ensureSessionLoaded(sessionID)
3629
- registerPendingCommandTurn(sessionID, output)
4447
+ // A fresh slash command is an authenticated boundary that may retry a
4448
+ // passive lease without waiting forever for an orphaned older reply.
4449
+ // Keep the old active guard installed during the asynchronous load so
4450
+ // tools from that older turn remain blocked; accepting this new command
4451
+ // in chat.message atomically replaces the guard.
4452
+ const loadResult = await ensureSessionLoaded(sessionID, {
4453
+ retryPassive: true,
4454
+ freshCommandBoundary: true,
4455
+ })
4456
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4457
+ const commandTurn = registerPendingCommandTurn(sessionID, output)
4458
+
4459
+ if (loadResult.kind === "passive") {
4460
+ commandTurn.passive = true
4461
+ replaceCommandOutputText(
4462
+ output,
4463
+ sessionOwnedElsewhereMessage(commandName, true, loadResult.reason),
4464
+ )
4465
+ return
4466
+ }
3630
4467
 
3631
4468
  if (typeof input.arguments !== "string") {
3632
4469
  replaceCommandOutputText(output, "Goal command arguments must be text.")
@@ -3648,7 +4485,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3648
4485
  replaceCommandOutputText(
3649
4486
  output,
3650
4487
  goal
3651
- ? formatStatus(goal, commandName)
4488
+ ? formatStatus(goal, commandName, completionAuditLabel)
3652
4489
  : lastResult
3653
4490
  ? formatGoalResult(lastResult)
3654
4491
  : `No active goal. Set one with \`/${commandName} <condition>\`.`,
@@ -3689,15 +4526,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3689
4526
  // sessionGoals.delete clears ALL backgrounded goals so they do not
3690
4527
  // resurrect as the focused goal on restart (cleanupGoal only removes the
3691
4528
  // focused one; background goals from `/goal add` would survive otherwise).
3692
- for (const goal of listSessionGoals(sessionID)) {
3693
- pushHistory(goal, "cleared", "User cleared the goal.")
3694
- }
4529
+ const goals = listSessionGoals(sessionID)
4530
+ const clearedGoal = goalStates.get(sessionID) || goals[0] || null
4531
+ const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
4532
+ const ledgerDurable =
4533
+ goals.length > 0 &&
4534
+ goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean)
3695
4535
  sessionOrdered.delete(sessionID)
3696
4536
  sessionGoals.delete(sessionID)
3697
4537
  cleanupGoal(sessionID)
3698
4538
  lastGoalResults.delete(sessionID)
3699
- await persist(sessionID)
3700
- replaceCommandOutputText(output, "Goal cleared.")
4539
+ const durable = await persistTerminalState(sessionID, "clear", ledgerDurable)
4540
+ const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
4541
+ if (hadState && clearStillCurrent) {
4542
+ announceLifecycle(sessionID, durable === false
4543
+ ? "Goal cleared in memory, but storage failed; it may reappear after restart."
4544
+ : "Goal cleared.", {
4545
+ goal: clearedGoal,
4546
+ transition: durable === false ? "clear-persistence-failed" : "cleared",
4547
+ requireCurrent: false,
4548
+ })
4549
+ }
4550
+ replaceCommandOutputText(
4551
+ output,
4552
+ !clearStillCurrent
4553
+ ? "Clear persistence finished after goal state changed; current state was left untouched."
4554
+ : durable === false
4555
+ ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
4556
+ : "Goal cleared.",
4557
+ )
3701
4558
  return
3702
4559
  }
3703
4560
 
@@ -3707,6 +4564,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3707
4564
  replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} <condition>\`.`)
3708
4565
  return
3709
4566
  }
4567
+ if (goal.stopped && goal.stopReason === "paused") {
4568
+ replaceCommandOutputText(output, "Goal is already paused.")
4569
+ return
4570
+ }
3710
4571
  currentRuntime().continuationControllers.get(sessionID)?.abort()
3711
4572
  goal.stopped = true
3712
4573
  goal.stopReason = "paused"
@@ -3715,6 +4576,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3715
4576
  activeContinues.delete(sessionID)
3716
4577
  pushHistory(goal, "paused", "User paused the active goal.")
3717
4578
  await persist(sessionID)
4579
+ announceLifecycle(sessionID, "Goal paused.", {
4580
+ goal,
4581
+ transition: "paused",
4582
+ reason: goal.stopReason,
4583
+ expectedState: "paused",
4584
+ expectedStopReason: "paused",
4585
+ })
3718
4586
  await abortAcceptedContinuation(sessionID)
3719
4587
  replaceCommandOutputText(output, `Goal paused: ${goal.condition}`)
3720
4588
  return
@@ -3741,6 +4609,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3741
4609
  goal.lastStatus = "Goal resumed with a fresh local budget."
3742
4610
  pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
3743
4611
  await persist(sessionID)
4612
+ announceLifecycle(sessionID, "Goal resumed with fresh limits.", {
4613
+ goal,
4614
+ transition: "resumed",
4615
+ expectedState: "active",
4616
+ })
3744
4617
  replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, {
3745
4618
  startsWork: true,
3746
4619
  })
@@ -3788,6 +4661,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3788
4661
  goal.lastStatus = "Goal objective updated."
3789
4662
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3790
4663
  await persist(sessionID)
4664
+ announceLifecycle(sessionID, "Goal updated and active.", {
4665
+ goal,
4666
+ transition: "updated-active",
4667
+ expectedState: "active",
4668
+ })
3791
4669
  replaceCommandOutputText(
3792
4670
  output,
3793
4671
  [
@@ -3868,6 +4746,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3868
4746
  focusGoal(sessionID, firstGoal)
3869
4747
  sessionOrdered.add(sessionID)
3870
4748
  await persist(sessionID)
4749
+ announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, {
4750
+ goal: firstGoal,
4751
+ transition: "sequence-active",
4752
+ reason: String(objectives.length),
4753
+ expectedState: "active",
4754
+ })
3871
4755
  replaceCommandOutputText(
3872
4756
  output,
3873
4757
  [
@@ -3933,6 +4817,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3933
4817
  pushHistory(target, "focused", "Brought into focus as the session's active goal.")
3934
4818
  focusGoal(sessionID, target)
3935
4819
  await persist(sessionID)
4820
+ announceLifecycle(sessionID, "Goal focus changed; selected goal active.", {
4821
+ goal: target,
4822
+ transition: "focused-active",
4823
+ expectedState: "active",
4824
+ })
3936
4825
  replaceCommandOutputText(
3937
4826
  output,
3938
4827
  [
@@ -3991,6 +4880,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3991
4880
  registerSessionGoal(added)
3992
4881
  focusGoal(sessionID, added)
3993
4882
  await persist(sessionID)
4883
+ announceLifecycle(sessionID, current
4884
+ ? "Goal added and active; previous goal backgrounded."
4885
+ : "Goal added and active.", {
4886
+ goal: added,
4887
+ transition: current ? "added-active-backgrounded" : "added-active",
4888
+ expectedState: "active",
4889
+ })
3994
4890
  const total = listSessionGoals(sessionID).length
3995
4891
  replaceCommandOutputText(
3996
4892
  output,
@@ -4029,6 +4925,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4029
4925
  registerSessionGoal(goal)
4030
4926
  focusGoal(sessionID, goal)
4031
4927
  await persist(sessionID)
4928
+ announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
4929
+ goal,
4930
+ transition: replacedGoal ? "replaced-active" : "active",
4931
+ expectedState: "active",
4932
+ })
4032
4933
  replaceCommandOutputText(
4033
4934
  output,
4034
4935
  [
@@ -4061,9 +4962,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4061
4962
 
4062
4963
  event: async ({ event }) => {
4063
4964
  const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
4064
- if (eventSessionID) await ensureSessionLoaded(eventSessionID)
4965
+ const loadResult = eventSessionID
4966
+ ? await ensureSessionLoaded(eventSessionID)
4967
+ : ACTIVE_PERSISTENCE_DISABLED
4968
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4969
+ const passive = loadResult.kind === "passive"
4065
4970
 
4066
- if (event?.type === "session.status") {
4971
+ if (!passive && event?.type === "session.status") {
4067
4972
  const sessionID = getSessionID(event)
4068
4973
  const status = event?.properties?.status?.type || event?.data?.status?.type
4069
4974
  if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
@@ -4071,22 +4976,42 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4071
4976
 
4072
4977
  if (event?.type === "session.updated") {
4073
4978
  const sessionID = getSessionID(event)
4074
- const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
4075
- if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
4979
+ rememberSessionExecutionContext(
4980
+ sessionID,
4981
+ event?.properties?.info || event?.data?.info,
4982
+ )
4076
4983
  }
4077
4984
 
4078
- if (event?.type === "message.updated") {
4985
+ if (!passive && event?.type === "message.updated") {
4079
4986
  const message = messageInfoFromEvent(event)
4080
4987
  if (messageRole(message) === "user") {
4081
- const context = normalizeExecutionContext(message)
4082
4988
  const sessionID = messageSessionID(message) || getSessionID(event)
4083
- if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
4989
+ rememberSessionExecutionContext(sessionID, message)
4084
4990
  }
4085
4991
  }
4086
4992
 
4993
+ const updatedMessage = event?.type === "message.updated"
4994
+ ? messageInfoFromEvent(event)
4995
+ : null
4996
+ const controlCommandAssistant = updatedMessage
4997
+ ? suppressControlCommandAssistant(updatedMessage)
4998
+ : false
4999
+
4087
5000
  const terminal = terminalEvent(event)
4088
5001
  if (terminal?.sessionID) {
4089
5002
  const runtime = currentRuntime()
5003
+ if (controlCommandAssistant) {
5004
+ // A provider error on a plugin-owned control reply belongs to that
5005
+ // read-only command turn, not to whichever goal may be active now.
5006
+ // This is especially important after passive takeover: a delayed
5007
+ // denial reply from the old lease epoch must not pause a newly
5008
+ // resumed goal. Retire only the exact active guard it answers.
5009
+ const activeCommandTurn = runtime.activeCommandTurns.get(terminal.sessionID)
5010
+ if (activeCommandTurn?.messageID === messageParentID(updatedMessage)) {
5011
+ runtime.activeCommandTurns.delete(terminal.sessionID)
5012
+ }
5013
+ return
5014
+ }
4090
5015
  const pendingTurns = runtime.pendingCommandTurns.get(terminal.sessionID)
4091
5016
  const resolvingCommandTurn = [...(pendingTurns?.values() || [])].reverse().find(
4092
5017
  (turn) => turn.preservedFileCount > 0,
@@ -4110,6 +5035,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4110
5035
  }
4111
5036
  if (!resolvingCommandAttachments) runtime.pendingCommandTurns.delete(terminal.sessionID)
4112
5037
  runtime.activeCommandTurns.delete(terminal.sessionID)
5038
+ if (passive) return
4113
5039
  await pauseActiveGoal(terminal.sessionID, {
4114
5040
  ...(resolvingCommandAttachments
4115
5041
  ? {
@@ -4126,6 +5052,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4126
5052
  return
4127
5053
  }
4128
5054
 
5055
+ if (event?.type === "message.updated") {
5056
+ if (passive || controlCommandAssistant === "passive") return
5057
+ }
5058
+
5059
+ if (passive) {
5060
+ if (isIdleEvent(event) && eventSessionID) {
5061
+ // A session-scoped idle can be stale or unrelated. Keep the passive
5062
+ // command guard until the latest assistant is proven to answer the
5063
+ // plugin-owned denial turn, matching the active-mode correlation
5064
+ // contract below.
5065
+ await retireCompletedCommandTurnOnIdle(
5066
+ eventSessionID,
5067
+ defaultGoalOptions.maxRecentMessages,
5068
+ )
5069
+ }
5070
+ return
5071
+ }
5072
+
4129
5073
  if (event?.type === "session.compacted") {
4130
5074
  const sessionID = getSessionID(event)
4131
5075
  const goal = goalStates.get(sessionID)
@@ -4144,25 +5088,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4144
5088
  if (!currentMessageID) return
4145
5089
  const currentSessionID = messageSessionID(message)
4146
5090
  const runtime = currentRuntime()
4147
- const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
4148
- const isControlCommandAssistant =
4149
- messageRole(message) === "assistant" &&
4150
- parentOwner?.kind === "command" &&
4151
- parentOwner?.policy === "control" &&
4152
- parentOwner?.sessionID === currentSessionID
4153
- if (isControlCommandAssistant) {
4154
- // A control command may produce several assistant messages (for
4155
- // example, a blocked tool-call step followed by a final report), and
4156
- // another plugin turn may overlap before all message.updated events
4157
- // arrive. Authenticate each response through its owned parent user
4158
- // message instead of relying on the session's single latest-command
4159
- // slot, then suppress it immediately for later idle processing.
4160
- setBoundedMessageValue(
4161
- runtime.suppressedCommandAssistants,
4162
- currentMessageID,
4163
- currentSessionID,
4164
- )
4165
- }
4166
5091
 
4167
5092
  const goal = goalStates.get(currentSessionID)
4168
5093
  if (!goal) return
@@ -4247,37 +5172,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4247
5172
  // remain suppressed in a bounded map so a later duplicate idle cannot
4248
5173
  // reinterpret the same report as goal progress or completion.
4249
5174
  const runtime = currentRuntime()
4250
- const activeCommandTurn = runtime.activeCommandTurns.get(sessionID)
4251
- let commandMessages = null
4252
- if (activeCommandTurn) {
4253
- const commandMessageLimit =
4254
- goalStates.get(sessionID)?.options.maxRecentMessages ||
4255
- defaultGoalOptions.maxRecentMessages
4256
- const commandHostMessages = await sessionApi.messages(sessionID, {
4257
- limit: commandMessageLimit,
4258
- })
4259
- commandMessages = Array.isArray(commandHostMessages)
4260
- ? commandHostMessages.slice(-commandMessageLimit)
4261
- : []
4262
- const commandAssistant = findLatestAssistantMessage(commandMessages)
4263
- if (
4264
- !commandAssistant ||
4265
- messageParentID(commandAssistant) !== activeCommandTurn.messageID
4266
- ) {
4267
- return
4268
- }
4269
- if (activeCommandTurn.policy === "control") {
4270
- const commandAssistantID = messageID(commandAssistant)
4271
- if (commandAssistantID) {
4272
- setBoundedMessageValue(
4273
- runtime.suppressedCommandAssistants,
4274
- commandAssistantID,
4275
- sessionID,
4276
- )
4277
- }
4278
- }
4279
- runtime.activeCommandTurns.delete(sessionID)
4280
- }
5175
+ const commandMessageLimit =
5176
+ goalStates.get(sessionID)?.options.maxRecentMessages ||
5177
+ defaultGoalOptions.maxRecentMessages
5178
+ const commandTurnState = await retireCompletedCommandTurnOnIdle(
5179
+ sessionID,
5180
+ commandMessageLimit,
5181
+ )
5182
+ if (!commandTurnState.ready) return
5183
+ const commandMessages = commandTurnState.messages
4281
5184
 
4282
5185
  const goal = goalStates.get(sessionID)
4283
5186
  if (!goal || goal.stopped || activeContinues.has(sessionID)) return
@@ -4394,7 +5297,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4394
5297
  auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
4395
5298
  pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
4396
5299
  await persist(sessionID)
4397
- await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
5300
+ const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
5301
+ if (
5302
+ rejectedGoalAfterPersist !== auditedGoal ||
5303
+ !auditedGoal.stopped ||
5304
+ auditedGoal.stopReason !== "audit rejected"
5305
+ ) return
5306
+ if (auditMessagesEnabled) {
5307
+ await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
5308
+ } else {
5309
+ announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
5310
+ goal: auditedGoal,
5311
+ transition: "audit-rejected",
5312
+ reason,
5313
+ expectedState: "paused",
5314
+ expectedStopReason: "audit rejected",
5315
+ })
5316
+ }
4398
5317
  return
4399
5318
  }
4400
5319
  pushHistory(
@@ -4414,23 +5333,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4414
5333
  `Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`,
4415
5334
  )
4416
5335
  const ordered = sessionOrdered.has(sessionID)
4417
- rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
5336
+ const completedResult = rememberGoalResult(
5337
+ sessionID,
5338
+ activeGoalAfterMessages,
5339
+ "achieved",
5340
+ "",
5341
+ evidence,
5342
+ )
4418
5343
  cleanupGoal(sessionID)
4419
5344
  // Ordered sequence: auto-promote the next goal so the
4420
5345
  // session keeps working through the sequence without manual /goal focus.
4421
- if (ordered) {
4422
- promoteNextOrderedGoal(sessionID)
4423
- }
5346
+ const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
5347
+ const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
4424
5348
  const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
4425
5349
  if (durable === false) {
4426
- restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
4427
- await announceAudit(
5350
+ const restored = restoreAfterTerminalPersistenceFailure(
4428
5351
  sessionID,
4429
- "Audit result: completion verified, but storage failed; goal remains paused and was not archived.",
5352
+ activeGoalAfterMessages,
5353
+ {
5354
+ ordered,
5355
+ expectedCurrentSnapshot: postCompletionSnapshot,
5356
+ expectedResult: completedResult,
5357
+ },
4430
5358
  )
5359
+ if (auditMessagesEnabled) {
5360
+ await announceAudit(
5361
+ sessionID,
5362
+ restored
5363
+ ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
5364
+ : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
5365
+ )
5366
+ } else {
5367
+ announceLifecycle(
5368
+ sessionID,
5369
+ restored
5370
+ ? "Goal paused — completion could not be recorded durably."
5371
+ : "Previous goal completion could not be confirmed durably after goal state changed.",
5372
+ restored
5373
+ ? {
5374
+ goal: activeGoalAfterMessages,
5375
+ transition: "terminal-persistence-failed",
5376
+ reason: activeGoalAfterMessages.stopReason,
5377
+ expectedState: "paused",
5378
+ expectedStopReason: "terminal persistence failed",
5379
+ }
5380
+ : {
5381
+ transition: "terminal-persistence-raced",
5382
+ requireCurrent: false,
5383
+ },
5384
+ )
5385
+ }
4431
5386
  return
4432
5387
  }
4433
- await announceAudit(sessionID, "Audit result: completion accepted — goal archived as achieved.")
5388
+ const activePromoted = promoted
5389
+ ? activeGoal(sessionID, promoted.goalId, promoted.runId)
5390
+ : null
5391
+ if (auditMessagesEnabled) {
5392
+ await announceAudit(
5393
+ sessionID,
5394
+ activePromoted
5395
+ ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
5396
+ : "Audit result: completion accepted — goal archived as achieved.",
5397
+ )
5398
+ } else {
5399
+ announceLifecycle(
5400
+ sessionID,
5401
+ activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
5402
+ {
5403
+ goal: activePromoted || activeGoalAfterMessages,
5404
+ transition: activePromoted ? "achieved-promoted" : "achieved",
5405
+ requireCurrent: Boolean(activePromoted),
5406
+ expectedState: activePromoted ? "active" : "",
5407
+ },
5408
+ )
5409
+ }
4434
5410
  return
4435
5411
  }
4436
5412
  completionUnverified = true
@@ -4456,16 +5432,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4456
5432
  blockedGoal.stopReason = "blocked"
4457
5433
  const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
4458
5434
  const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
5435
+ const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
5436
+ if (
5437
+ blockedGoalAfterPersist !== blockedGoal ||
5438
+ !blockedGoal.stopped ||
5439
+ blockedGoal.stopReason !== "blocked"
5440
+ ) return
4459
5441
  if (durable === false) {
4460
5442
  blockedGoal.stopReason = "terminal persistence failed"
4461
5443
  blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
4462
- await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
5444
+ if (auditMessagesEnabled) {
5445
+ await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
5446
+ } else {
5447
+ announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
5448
+ goal: blockedGoal,
5449
+ transition: "terminal-persistence-failed",
5450
+ reason: blockedGoal.stopReason,
5451
+ expectedState: "paused",
5452
+ expectedStopReason: "terminal persistence failed",
5453
+ })
5454
+ }
4463
5455
  return
4464
5456
  }
4465
- await announceAudit(
4466
- sessionID,
4467
- `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
4468
- )
5457
+ if (auditMessagesEnabled) {
5458
+ await announceAudit(
5459
+ sessionID,
5460
+ `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
5461
+ )
5462
+ } else {
5463
+ announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
5464
+ goal: blockedGoal,
5465
+ transition: "blocked",
5466
+ expectedState: "blocked",
5467
+ expectedStopReason: "blocked",
5468
+ })
5469
+ }
4469
5470
  return
4470
5471
  }
4471
5472
  blockerUnstated = true
@@ -4480,6 +5481,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4480
5481
 
4481
5482
  const limitReason = stopReason(activeGoalAfterMessages)
4482
5483
  if (limitReason) {
5484
+ let lifecycleAnnounced = false
4483
5485
  if (!activeGoalAfterMessages.budgetWrapupSent) {
4484
5486
  const claimedGoal = await claimContinuationSource(
4485
5487
  sessionID,
@@ -4496,6 +5498,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4496
5498
  claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
4497
5499
  pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
4498
5500
  await persist(sessionID)
5501
+ lifecycleAnnounced = announceLifecycle(
5502
+ sessionID,
5503
+ `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`,
5504
+ {
5505
+ goal: claimedGoal,
5506
+ transition: "limit-paused",
5507
+ reason: limitReason,
5508
+ expectedState: "paused",
5509
+ expectedStopReason: limitReason,
5510
+ },
5511
+ )
4499
5512
  currentRuntime().promptInFlightSessions.add(sessionID)
4500
5513
  let response
4501
5514
  try {
@@ -4522,6 +5535,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4522
5535
  pushHistory(activeGoalAfterMessages, "limit", limitReason)
4523
5536
  }
4524
5537
  await persist(sessionID)
5538
+ if (!lifecycleAnnounced) {
5539
+ announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, {
5540
+ goal: activeGoalAfterMessages,
5541
+ transition: "limit-paused",
5542
+ reason: limitReason,
5543
+ expectedState: "paused",
5544
+ expectedStopReason: limitReason,
5545
+ })
5546
+ }
4525
5547
  return
4526
5548
  }
4527
5549
 
@@ -4577,6 +5599,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4577
5599
  `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
4578
5600
  )
4579
5601
  await persist(sessionID)
5602
+ announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", {
5603
+ goal: activeGoalAfterMessages,
5604
+ transition: "no-progress-paused",
5605
+ reason: activeGoalAfterMessages.stopReason,
5606
+ expectedState: "paused",
5607
+ expectedStopReason: "no progress",
5608
+ })
4580
5609
  return
4581
5610
  }
4582
5611
 
@@ -4622,6 +5651,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4622
5651
  `Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
4623
5652
  )
4624
5653
  await persist(sessionID)
5654
+ announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", {
5655
+ goal: activeGoalAfterMessages,
5656
+ transition: "no-tool-calls-paused",
5657
+ reason: activeGoalAfterMessages.stopReason,
5658
+ expectedState: "paused",
5659
+ expectedStopReason: "no tool calls",
5660
+ })
4625
5661
  return
4626
5662
  }
4627
5663
 
@@ -4671,6 +5707,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4671
5707
  // the hard-limit path which also persists before its promptAsync call.
4672
5708
  pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
4673
5709
  await persist(sessionID)
5710
+ announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", {
5711
+ goal: activeGoalBeforePrompt,
5712
+ transition: "budget-wrapup-paused",
5713
+ reason: activeGoalBeforePrompt.stopReason,
5714
+ expectedState: "paused",
5715
+ expectedStopReason: "budget wrap-up requested",
5716
+ })
4674
5717
  }
4675
5718
 
4676
5719
  activeGoalBeforePrompt.turnCount += 1
@@ -4711,6 +5754,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4711
5754
  `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
4712
5755
  )
4713
5756
  await persist(sessionID)
5757
+ announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", {
5758
+ goal: activeGoalBeforePrompt,
5759
+ transition: "format-failures-paused",
5760
+ reason: activeGoalBeforePrompt.stopReason,
5761
+ expectedState: "paused",
5762
+ expectedStopReason: "format validation failures",
5763
+ })
4714
5764
  return
4715
5765
  }
4716
5766
  }
@@ -4735,6 +5785,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4735
5785
  currentRuntime().promptInFlightSessions.delete(sessionID)
4736
5786
  }
4737
5787
 
5788
+ let promptFailurePausedGoal = null
4738
5789
  if (response.error) {
4739
5790
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
4740
5791
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
@@ -4750,6 +5801,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4750
5801
  activeGoalAfterPrompt.stopped = true
4751
5802
  activeGoalAfterPrompt.stopReason = "auto-continue failures"
4752
5803
  activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.`
5804
+ promptFailurePausedGoal = activeGoalAfterPrompt
4753
5805
  }
4754
5806
  }
4755
5807
  await logPluginError(client, message, response.error)
@@ -4773,6 +5825,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4773
5825
  }
4774
5826
  }
4775
5827
  await persist(sessionID)
5828
+ if (promptFailurePausedGoal) {
5829
+ announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
5830
+ goal: promptFailurePausedGoal,
5831
+ transition: "prompt-failures-paused",
5832
+ reason: promptFailurePausedGoal.stopReason,
5833
+ expectedState: "paused",
5834
+ expectedStopReason: "auto-continue failures",
5835
+ })
5836
+ }
4776
5837
  } catch (error) {
4777
5838
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
4778
5839
  if (activeGoalAfterError) {
@@ -4793,6 +5854,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4793
5854
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
4794
5855
  }
4795
5856
  await persist(sessionID)
5857
+ if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") {
5858
+ announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
5859
+ goal: activeGoalAfterError,
5860
+ transition: "prompt-failures-paused",
5861
+ reason: activeGoalAfterError.stopReason,
5862
+ expectedState: "paused",
5863
+ expectedStopReason: "auto-continue failures",
5864
+ })
5865
+ }
4796
5866
  }
4797
5867
  await logPluginError(client, "Auto-continue failed", error)
4798
5868
  } finally {
@@ -4809,11 +5879,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4809
5879
 
4810
5880
  "experimental.chat.system.transform": async (input, output) => {
4811
5881
  if (!input.sessionID) return
4812
- await ensureSessionLoaded(input.sessionID)
5882
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5883
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4813
5884
 
4814
5885
  const activeCommandTurn = currentRuntime().activeCommandTurns.get(input.sessionID)
4815
5886
  const commandGuarded = activeCommandTurn?.policy === "control"
4816
- const goal = goalStates.get(input.sessionID)
5887
+ const goal = loadResult.kind === "active" ? goalStates.get(input.sessionID) : null
4817
5888
  if (!goal && !commandGuarded) return
4818
5889
  const blockID = goal?.goalId || `command-${activeCommandTurn.id}`
4819
5890
  const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
@@ -4870,7 +5941,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4870
5941
 
4871
5942
  "experimental.session.compacting": async (input, output) => {
4872
5943
  if (!input?.sessionID || !output) return
4873
- await ensureSessionLoaded(input.sessionID)
5944
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5945
+ if (currentRuntime().disposed || loadResult.kind !== "active") return
4874
5946
  const goal = goalStates.get(input.sessionID)
4875
5947
  if (!goal) return
4876
5948
  const context = buildCompactionContext(goal)
@@ -4890,7 +5962,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4890
5962
  // auto-continue to avoid two continuations racing after a compaction.
4891
5963
  // Paused/stopped goals leave the native behavior untouched.
4892
5964
  if (!input?.sessionID || !output) return
4893
- await ensureSessionLoaded(input.sessionID)
5965
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5966
+ if (currentRuntime().disposed || loadResult.kind !== "active") return
4894
5967
  const goal = goalStates.get(input.sessionID)
4895
5968
  if (!goal || goal.stopped) return
4896
5969
  output.enabled = false
@@ -4907,7 +5980,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4907
5980
  // makes this deterministic for normal npm installs; `registerTools: false`
4908
5981
  // remains the explicit opt-out.
4909
5982
  if (pluginOptions.registerTools !== false) {
4910
- hooks.tool = buildAgentTools(bundledToolHelper, agentToolHandlers, ensureSessionLoaded)
5983
+ hooks.tool = buildAgentTools(
5984
+ bundledToolHelper,
5985
+ agentToolHandlers,
5986
+ ensureSessionLoaded,
5987
+ commandName,
5988
+ () => runtime.disposed,
5989
+ registerCommand,
5990
+ )
4911
5991
  }
4912
5992
 
4913
5993
  return hooks
@@ -4987,6 +6067,7 @@ export default {
4987
6067
  }
4988
6068
 
4989
6069
  export const testInternals = {
6070
+ commandTurnTtlMs: COMMAND_TURN_TTL_MS,
4990
6071
  activeGoal,
4991
6072
  agentToolSessionID,
4992
6073
  buildAgentToolHandlers,
@@ -5000,6 +6081,7 @@ export const testInternals = {
5000
6081
  ledgerPathFor,
5001
6082
  setLedgerSink,
5002
6083
  defaultAuditMessenger,
6084
+ defaultLifecycleMessenger,
5003
6085
  buildAuditPrompt,
5004
6086
  parseAuditVerdict,
5005
6087
  createChildSessionAuditor,
@@ -5018,6 +6100,7 @@ export const testInternals = {
5018
6100
  extractCompletionEvidence,
5019
6101
  findLatestAssistantMessage,
5020
6102
  formatArgumentErrors,
6103
+ goalDisplayState,
5021
6104
  formatStatus,
5022
6105
  getSessionID,
5023
6106
  goalIsBlocked,
@@ -5042,6 +6125,7 @@ export const testInternals = {
5042
6125
  parseTokenBudget,
5043
6126
  pruneGoalResults,
5044
6127
  resolveStateFilePath,
6128
+ runtimeSessionDiagnostics,
5045
6129
  stopReason,
5046
6130
  xdgStateFilePath,
5047
6131
  }