opencode-goal-plugin 0.6.4 → 0.6.6

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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto"
1
+ import { createHash, randomUUID } from "node:crypto"
2
2
  import { AsyncLocalStorage } from "node:async_hooks"
3
3
  import {
4
4
  promises as fs,
@@ -51,6 +51,8 @@ const MAX_TRACKED_MESSAGE_IDS = 20_000
51
51
  const DEFAULT_LEDGER_MAX_BYTES = 2 * 1024 * 1024
52
52
  const DEFAULT_LEDGER_RETENTION_FILES = 3
53
53
  const MAX_LEDGER_LINE_BYTES = 16 * 1024
54
+ const MIGRATION_LEASE_RETRIES = 200
55
+ const MIGRATION_LEASE_DELAY_MS = 25
54
56
 
55
57
  const DEFAULT_OPTIONS = {
56
58
  maxTurns: 10,
@@ -74,7 +76,7 @@ const DEFAULT_OPTIONS = {
74
76
  // handler drives and that the system-prompt transform injects. `sessionGoals`
75
77
  // is the full registry of live goals per session (focused + backgrounded);
76
78
  // the focused goal is the same object reference held in both. `sessionArchive`
77
- // keeps a capped list of completed/cleared goals so they stay readable.
79
+ // keeps a capped list of achieved goals so completed work stays readable.
78
80
  function createRuntimeState() {
79
81
  return {
80
82
  goalStates: new Map(),
@@ -93,9 +95,8 @@ function createRuntimeState() {
93
95
  sessionExecutionContexts: new Map(),
94
96
  readOnlyCommandGuards: new Set(),
95
97
  ledgerSink: null,
96
- persistenceLease: null,
97
- migrationLease: null,
98
- drainPersistence: null,
98
+ sessionPersistence: new Map(),
99
+ sessionLoadPromises: new Map(),
99
100
  disposed: false,
100
101
  }
101
102
  }
@@ -129,7 +130,7 @@ function runtimeCollection(name) {
129
130
  const goalStates = runtimeCollection("goalStates")
130
131
  const sessionGoals = runtimeCollection("sessionGoals")
131
132
  const sessionArchive = runtimeCollection("sessionArchive")
132
- // Sessions running an ordered (sisyphus) sequence: when the focused goal
133
+ // Sessions running an ordered sequence: when the focused goal
133
134
  // completes, the next live goal (in creation order) is auto-promoted to focus
134
135
  // so the sequence advances on its own.
135
136
  const sessionOrdered = runtimeCollection("sessionOrdered")
@@ -147,6 +148,9 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
147
148
  const activeContinues = runtimeCollection("activeContinues")
148
149
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
149
150
  const PAUSE_COMMANDS = new Set(["pause"])
151
+ // `sequence` is canonical. The former public spelling remains accepted at
152
+ // the parser boundary so existing scripts do not break.
153
+ const SEQUENCE_COMMANDS = ["sequence", "sisyphus"]
150
154
  const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
151
155
  const GOAL_FLAG_SPECS = {
152
156
  "--max-turns": {
@@ -211,8 +215,8 @@ function messageHasToolCall(message) {
211
215
 
212
216
  const GOAL_MODES = new Set(["normal", "ordered"])
213
217
 
214
- // Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered`
215
- // signals a strict execution sequence; `sisyphus` is accepted as an alias.
218
+ // Goal mode: normal vs ordered. The former public spelling remains accepted
219
+ // as an input alias, while stored state and output always use `ordered`.
216
220
  // Returns the canonical mode or null when unrecognized.
217
221
  function normalizeMode(value) {
218
222
  const normalized = String(value || "").trim().toLowerCase()
@@ -681,12 +685,6 @@ function listSessionGoals(sessionID) {
681
685
  return map ? [...map.values()] : []
682
686
  }
683
687
 
684
- function totalLiveGoals() {
685
- let total = 0
686
- for (const goals of sessionGoals.values()) total += goals.size
687
- return total
688
- }
689
-
690
688
  function rememberMessageID(goal, messageID) {
691
689
  goal.messageIDs.add(messageID)
692
690
  while (goal.messageIDs.size > MAX_MESSAGE_IDS_PER_GOAL) {
@@ -727,7 +725,7 @@ function archiveSessionResult(sessionID, result) {
727
725
  sessionArchive.set(sessionID, list.slice(-MAX_ARCHIVED_PER_SESSION))
728
726
  }
729
727
 
730
- // Advance an ordered (sisyphus) sequence: focus the next live goal in creation
728
+ // Advance an ordered sequence: focus the next live goal in creation
731
729
  // order, clearing any backgrounded state so the idle handler drives it. Returns
732
730
  // the promoted goal, or null when the sequence is exhausted (which also clears
733
731
  // the session's ordered flag).
@@ -743,7 +741,7 @@ function promoteNextOrderedGoal(sessionID) {
743
741
  resumeGoalClock(next)
744
742
  next.skipNextTerminalCheck = true
745
743
  next.lastStatus = "Promoted as the next ordered goal."
746
- pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered (sisyphus) sequence.")
744
+ pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered sequence.")
747
745
  focusGoal(sessionID, next)
748
746
  return next
749
747
  }
@@ -786,6 +784,29 @@ function clearRuntimeState() {
786
784
  runtime.readOnlyCommandGuards.clear()
787
785
  }
788
786
 
787
+ function clearSessionRuntimeState(sessionID) {
788
+ const runtime = currentRuntime()
789
+ for (const goal of sessionGoals.get(sessionID)?.values() || []) {
790
+ for (const messageID of goal.messageIDs || []) {
791
+ seenTokens.delete(messageID)
792
+ seenUsage.delete(messageID)
793
+ seenOutputTokens.delete(messageID)
794
+ }
795
+ }
796
+ runtime.continuationControllers.get(sessionID)?.abort()
797
+ goalStates.delete(sessionID)
798
+ sessionGoals.delete(sessionID)
799
+ sessionArchive.delete(sessionID)
800
+ sessionOrdered.delete(sessionID)
801
+ lastGoalResults.delete(sessionID)
802
+ activeContinues.delete(sessionID)
803
+ runtime.continuationControllers.delete(sessionID)
804
+ runtime.promptInFlightSessions.delete(sessionID)
805
+ runtime.sessionStatuses.delete(sessionID)
806
+ runtime.sessionExecutionContexts.delete(sessionID)
807
+ runtime.readOnlyCommandGuards.delete(sessionID)
808
+ }
809
+
789
810
  function pruneGoalResults(options) {
790
811
  const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
791
812
  const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
@@ -994,6 +1015,26 @@ function ledgerPathFor(stateFilePath) {
994
1015
  return `${stateFilePath}.ledger.jsonl`
995
1016
  }
996
1017
 
1018
+ // Persist each OpenCode session in its own directory. Session IDs are hashed so
1019
+ // arbitrary host-provided IDs cannot become path components, and the resulting
1020
+ // paths are portable across POSIX and Windows filesystems.
1021
+ function sessionDirectoryFor(stateFilePath) {
1022
+ return `${stateFilePath}.sessions`
1023
+ }
1024
+
1025
+ function sessionKey(sessionID) {
1026
+ return createHash("sha256").update(sessionID).digest("hex")
1027
+ }
1028
+
1029
+ function sessionPathsFor(persistenceOptions, sessionID) {
1030
+ const directory = join(persistenceOptions.sessionDirectory, sessionKey(sessionID))
1031
+ const stateFilePath = join(directory, "state.json")
1032
+ return {
1033
+ stateFilePath,
1034
+ ledgerFilePath: ledgerPathFor(stateFilePath),
1035
+ }
1036
+ }
1037
+
997
1038
  // XDG-style state path: $XDG_STATE_HOME/opencode-goal-plugin/state.json,
998
1039
  // defaulting to ~/.local/state when XDG_STATE_HOME is unset.
999
1040
  function xdgStateFilePath(env = process.env) {
@@ -1044,11 +1085,14 @@ function normalizePersistenceOptions(options = {}, { env = process.env, cwd } =
1044
1085
  : ledgerPathFor(stateFilePath)
1045
1086
  const ledgerMaxBytes = toPositiveInteger(options.ledgerMaxBytes, DEFAULT_LEDGER_MAX_BYTES)
1046
1087
  const ledgerRetentionFiles = Number.isSafeInteger(options.ledgerRetentionFiles) && options.ledgerRetentionFiles >= 0
1047
- ? Math.min(options.ledgerRetentionFiles, 10)
1048
- : DEFAULT_LEDGER_RETENTION_FILES
1088
+ ? Math.min(options.ledgerRetentionFiles, 10)
1089
+ : DEFAULT_LEDGER_RETENTION_FILES
1090
+ const sessionDirectory = sessionDirectoryFor(stateFilePath)
1049
1091
  return {
1050
1092
  persistState,
1051
1093
  stateFilePath,
1094
+ sessionDirectory,
1095
+ migrationMarkerPath: join(sessionDirectory, ".migration-v1-complete"),
1052
1096
  fallbackPaths,
1053
1097
  ledgerFilePath,
1054
1098
  ledgerMaxBytes,
@@ -1280,7 +1324,7 @@ function deserializeGoal(goal) {
1280
1324
  // Parse one state-file body and apply it to runtime state. Returns "loaded" on
1281
1325
  // success or "invalid" when the version/shape is unsupported. Throws on
1282
1326
  // JSON.parse failure (handled by the caller).
1283
- async function applyParsedStateFile(raw, client) {
1327
+ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1284
1328
  const parsed = JSON.parse(raw)
1285
1329
  if (parsed?.version !== STATE_FILE_VERSION) {
1286
1330
  await logPluginError(
@@ -1300,6 +1344,7 @@ async function applyParsedStateFile(raw, client) {
1300
1344
  const loadedGoalCounts = new Map()
1301
1345
  for (const rawGoal of parsed.goals.slice(0, MAX_PERSISTED_ENTRIES)) {
1302
1346
  const normalizedGoal = normalizePersistedGoal(rawGoal)
1347
+ if (onlySessionID && normalizedGoal?.sessionID !== onlySessionID) continue
1303
1348
  const sessionCount = normalizedGoal
1304
1349
  ? loadedGoalCounts.get(normalizedGoal.sessionID) || 0
1305
1350
  : 0
@@ -1315,6 +1360,7 @@ async function applyParsedStateFile(raw, client) {
1315
1360
  let skippedResults = 0
1316
1361
  for (const rawResult of parsed.results.slice(-MAX_PERSISTED_ENTRIES)) {
1317
1362
  const normalizedResult = normalizePersistedResult(rawResult)
1363
+ if (onlySessionID && normalizedResult?.sessionID !== onlySessionID) continue
1318
1364
  if (normalizedResult) {
1319
1365
  loadedResults.push(normalizedResult)
1320
1366
  } else {
@@ -1329,7 +1375,8 @@ async function applyParsedStateFile(raw, client) {
1329
1375
  )
1330
1376
  }
1331
1377
 
1332
- clearRuntimeState()
1378
+ if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1379
+ else clearRuntimeState()
1333
1380
 
1334
1381
  const focusBySession = new Map()
1335
1382
  for (const { goal, focused } of loadedGoals) {
@@ -1342,6 +1389,7 @@ async function applyParsedStateFile(raw, client) {
1342
1389
  // Restore focus. Older single-goal state files have no `focused` flag, so
1343
1390
  // fall back to focusing a session's first (typically only) goal.
1344
1391
  for (const [sessionID, goalMap] of sessionGoals.entries()) {
1392
+ if (onlySessionID && sessionID !== onlySessionID) continue
1345
1393
  const focusTarget = focusBySession.get(sessionID) || goalMap.values().next().value
1346
1394
  if (focusTarget) focusGoal(sessionID, focusTarget)
1347
1395
  }
@@ -1353,6 +1401,7 @@ async function applyParsedStateFile(raw, client) {
1353
1401
  if (Array.isArray(parsed.archives)) {
1354
1402
  for (const entry of parsed.archives.slice(-MAX_PERSISTED_ENTRIES)) {
1355
1403
  if (!isPlainObject(entry) || typeof entry.sessionID !== "string" || !entry.sessionID) continue
1404
+ if (onlySessionID && entry.sessionID !== onlySessionID) continue
1356
1405
  const results = Array.isArray(entry.results)
1357
1406
  ? entry.results.map(normalizePersistedResult).filter(Boolean)
1358
1407
  : []
@@ -1364,6 +1413,7 @@ async function applyParsedStateFile(raw, client) {
1364
1413
 
1365
1414
  if (Array.isArray(parsed.orderedSessions)) {
1366
1415
  for (const sessionID of parsed.orderedSessions) {
1416
+ if (onlySessionID && sessionID !== onlySessionID) continue
1367
1417
  // Only honor the ordered flag for sessions that still have goals loaded.
1368
1418
  if (typeof sessionID === "string" && sessionGoals.has(sessionID)) {
1369
1419
  sessionOrdered.add(sessionID)
@@ -1378,7 +1428,7 @@ async function applyParsedStateFile(raw, client) {
1378
1428
  // terminal events. If a goal has a "completed" or "cleared" entry in the ledger
1379
1429
  // but still appears active in the state file (because the state write failed
1380
1430
  // after the terminal ledger write), remove it so it is not re-driven.
1381
- async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1431
+ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
1382
1432
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1383
1433
  maxBytes: persistenceOptions.ledgerMaxBytes,
1384
1434
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
@@ -1392,6 +1442,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1392
1442
  typeof entry.sessionID === "string" && entry.sessionID &&
1393
1443
  typeof entry.goalId === "string" && entry.goalId
1394
1444
  ) {
1445
+ if (onlySessionID && entry.sessionID !== onlySessionID) continue
1395
1446
  terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
1396
1447
  }
1397
1448
  }
@@ -1399,6 +1450,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1399
1450
 
1400
1451
  let removed = 0
1401
1452
  for (const [sessionID, goals] of sessionGoals.entries()) {
1453
+ if (onlySessionID && sessionID !== onlySessionID) continue
1402
1454
  for (const goal of [...goals.values()]) {
1403
1455
  if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
1404
1456
  removeSessionGoal(sessionID, goal.goalId)
@@ -1417,114 +1469,250 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1417
1469
  }
1418
1470
  }
1419
1471
 
1420
- async function loadPersistedState(persistenceOptions, client) {
1421
- if (!persistenceOptions.persistState) return "disabled"
1472
+ async function pathExists(path) {
1473
+ try {
1474
+ await fs.lstat(path)
1475
+ return true
1476
+ } catch (error) {
1477
+ if (error?.code === "ENOENT") return false
1478
+ throw error
1479
+ }
1480
+ }
1422
1481
 
1423
- const candidates = [
1424
- { path: persistenceOptions.stateFilePath, primary: true },
1425
- ...(persistenceOptions.fallbackPaths || []).map((path) => ({ path, primary: false })),
1426
- ]
1427
- const recoverInvalidPrimary = async () => {
1428
- const status = await reconstructFromLedger(persistenceOptions, client)
1429
- if (status !== "reconstructed") return "invalid"
1430
- const quarantinePath = `${persistenceOptions.stateFilePath}.corrupt.${Date.now()}.${randomUUID()}`
1482
+ async function acquireMigrationLease(stateFilePath, migrationMarkerPath) {
1483
+ let lastError
1484
+ for (let attempt = 0; attempt < MIGRATION_LEASE_RETRIES; attempt += 1) {
1485
+ if (await pathExists(migrationMarkerPath)) return null
1431
1486
  try {
1432
- await fs.rename(persistenceOptions.stateFilePath, quarantinePath)
1487
+ return await acquirePersistenceLease(stateFilePath)
1488
+ } catch (error) {
1489
+ if (!String(error?.message || error).includes("goal persistence is already owned")) throw error
1490
+ lastError = error
1491
+ await new Promise((resolve) => setTimeout(resolve, MIGRATION_LEASE_DELAY_MS))
1492
+ }
1493
+ }
1494
+ throw lastError || new Error("could not acquire goal migration lease")
1495
+ }
1496
+
1497
+ async function readPersistedStateFile(path, client) {
1498
+ let raw
1499
+ try {
1500
+ const info = await fs.lstat(path)
1501
+ if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_STATE_FILE_BYTES) {
1433
1502
  await logPluginError(
1434
1503
  client,
1435
- `Preserved invalid persisted goal state at ${quarantinePath} before ledger recovery.`,
1504
+ `Skipped persisted goal state: file is not regular or exceeds ${MAX_STATE_FILE_BYTES} bytes.`,
1436
1505
  )
1437
- } catch (error) {
1438
- await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
1439
- return "invalid"
1506
+ return { status: "invalid" }
1440
1507
  }
1441
- return status
1508
+ raw = await fs.readFile(path, "utf8")
1509
+ } catch (error) {
1510
+ if (error?.code === "ENOENT") return { status: "missing" }
1511
+ await logPluginError(client, "Failed to load persisted goal state", error)
1512
+ return { status: "invalid" }
1442
1513
  }
1443
1514
 
1444
- for (const { path, primary } of candidates) {
1445
- let migrationLease = null
1446
- if (!primary) {
1447
- try {
1448
- migrationLease = await acquirePersistenceLease(path)
1449
- currentRuntime().migrationLease = migrationLease
1450
- } catch (error) {
1451
- await logPluginError(client, `Skipped legacy state migration because another process owns ${path}.`, error)
1452
- continue
1453
- }
1515
+ try {
1516
+ const parsed = JSON.parse(raw)
1517
+ if (parsed?.version !== STATE_FILE_VERSION || !Array.isArray(parsed.goals) || !Array.isArray(parsed.results)) {
1518
+ await logPluginError(client, `Skipped persisted goal state: unsupported or malformed state at ${path}.`)
1519
+ return { status: "invalid" }
1454
1520
  }
1455
- let raw
1521
+ } catch (error) {
1522
+ await logPluginError(client, "Failed to parse persisted goal state", error)
1523
+ return { status: "invalid" }
1524
+ }
1525
+ return { status: "loaded", raw }
1526
+ }
1527
+
1528
+ function migrationCandidates(persistenceOptions) {
1529
+ return [
1530
+ {
1531
+ stateFilePath: persistenceOptions.stateFilePath,
1532
+ ledgerFilePath: persistenceOptions.ledgerFilePath,
1533
+ },
1534
+ ...(persistenceOptions.fallbackPaths || []).map((stateFilePath) => ({
1535
+ stateFilePath,
1536
+ ledgerFilePath: ledgerPathFor(stateFilePath),
1537
+ })),
1538
+ ]
1539
+ }
1540
+
1541
+ function sessionStatePayload(sessionID, parsedState, ledgerEntries = []) {
1542
+ const goals = []
1543
+ const results = []
1544
+ const archives = []
1545
+ const orderedSessions = []
1546
+
1547
+ for (const rawGoal of parsedState?.goals || []) {
1548
+ const goal = normalizePersistedGoal(rawGoal)
1549
+ if (!goal || goal.sessionID !== sessionID) continue
1550
+ goals.push({ ...serializeGoal(goal), focused: rawGoal?.focused === true })
1551
+ }
1552
+
1553
+ for (const rawResult of parsedState?.results || []) {
1554
+ const result = normalizePersistedResult(rawResult)
1555
+ if (result?.sessionID === sessionID) results.push(result)
1556
+ }
1557
+
1558
+ for (const rawArchive of parsedState?.archives || []) {
1559
+ if (!isPlainObject(rawArchive) || rawArchive.sessionID !== sessionID) continue
1560
+ const archiveResults = Array.isArray(rawArchive.results)
1561
+ ? rawArchive.results.map(normalizePersistedResult).filter((result) => result?.sessionID === sessionID)
1562
+ : []
1563
+ if (archiveResults.length) archives.push({ sessionID, results: archiveResults.slice(-MAX_ARCHIVED_PER_SESSION) })
1564
+ }
1565
+
1566
+ if (parsedState?.orderedSessions?.includes(sessionID)) orderedSessions.push(sessionID)
1567
+
1568
+ const sessionLedger = ledgerEntries.filter((entry) => entry?.sessionID === sessionID)
1569
+ const knownGoalIDs = new Set(goals.map((goal) => goal.goalId))
1570
+ for (const reconstructed of reconstructGoalsFromLedger(sessionLedger)) {
1571
+ const goal = normalizePersistedGoal(reconstructed)
1572
+ if (!goal || knownGoalIDs.has(goal.goalId)) continue
1573
+ goals.push({ ...serializeGoal(goal), focused: true })
1574
+ knownGoalIDs.add(goal.goalId)
1575
+ if (reconstructed.ordered === true && !orderedSessions.includes(sessionID)) orderedSessions.push(sessionID)
1576
+ }
1577
+
1578
+ return {
1579
+ version: STATE_FILE_VERSION,
1580
+ goals: goals.slice(-MAX_PERSISTED_ENTRIES),
1581
+ results: results.slice(-MAX_PERSISTED_ENTRIES),
1582
+ archives,
1583
+ orderedSessions,
1584
+ }
1585
+ }
1586
+
1587
+ async function writeStateSnapshot(stateFilePath, payload) {
1588
+ const tmpPath = `${stateFilePath}.${process.pid}.${randomUUID()}.tmp`
1589
+ try {
1590
+ await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
1591
+ await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 })
1592
+ await fs.rename(tmpPath, stateFilePath)
1593
+ await fs.chmod(stateFilePath, 0o600)
1594
+ return true
1595
+ } catch (error) {
1596
+ await fs.rm(tmpPath, { force: true }).catch(() => {})
1597
+ throw error
1598
+ }
1599
+ }
1600
+
1601
+ async function writeMigrationMarker(path) {
1602
+ await writeStateSnapshot(path, { version: 1, migratedAt: Date.now() })
1603
+ }
1604
+
1605
+ async function migrateLegacyState(persistenceOptions, client) {
1606
+ if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1607
+
1608
+ for (const candidate of migrationCandidates(persistenceOptions)) {
1609
+ const sourceHasState = await pathExists(candidate.stateFilePath)
1610
+ const sourceHasLedger = await pathExists(candidate.ledgerFilePath)
1611
+ if (!sourceHasState && !sourceHasLedger) continue
1612
+
1613
+ const migrationLease = await acquireMigrationLease(
1614
+ candidate.stateFilePath,
1615
+ persistenceOptions.migrationMarkerPath,
1616
+ )
1617
+ if (!migrationLease) return
1456
1618
  try {
1457
- const info = await fs.lstat(path)
1458
- if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_STATE_FILE_BYTES) {
1459
- await logPluginError(
1460
- client,
1461
- `Skipped persisted goal state: file is not regular or exceeds ${MAX_STATE_FILE_BYTES} bytes.`,
1462
- )
1463
- if (primary) return recoverInvalidPrimary()
1464
- await migrationLease?.release()
1465
- continue
1619
+ if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1620
+
1621
+ const state = await readPersistedStateFile(candidate.stateFilePath, client)
1622
+ const ledgerEntries = await readLedgerEntries(candidate.ledgerFilePath, {
1623
+ maxBytes: persistenceOptions.ledgerMaxBytes,
1624
+ retentionFiles: persistenceOptions.ledgerRetentionFiles,
1625
+ })
1626
+ if (state.status === "invalid" && ledgerEntries.length === 0) return
1627
+ if (state.status === "missing" && ledgerEntries.length === 0) return
1628
+
1629
+ const parsedState = state.status === "loaded" ? JSON.parse(state.raw) : null
1630
+ const sessionIDs = new Set(ledgerEntries.map((entry) => entry?.sessionID).filter(Boolean))
1631
+ for (const rawGoal of parsedState?.goals || []) if (rawGoal?.sessionID) sessionIDs.add(rawGoal.sessionID)
1632
+ for (const rawResult of parsedState?.results || []) if (rawResult?.sessionID) sessionIDs.add(rawResult.sessionID)
1633
+ for (const rawArchive of parsedState?.archives || []) if (rawArchive?.sessionID) sessionIDs.add(rawArchive.sessionID)
1634
+ for (const orderedSession of parsedState?.orderedSessions || []) if (orderedSession) sessionIDs.add(orderedSession)
1635
+
1636
+ for (const sessionID of [...sessionIDs].sort()) {
1637
+ const targetPaths = sessionPathsFor(persistenceOptions, sessionID)
1638
+ if (await pathExists(targetPaths.stateFilePath)) continue
1639
+
1640
+ const payload = sessionStatePayload(sessionID, parsedState, ledgerEntries)
1641
+ const sessionLedger = ledgerEntries.filter((entry) => entry?.sessionID === sessionID)
1642
+ if (sessionLedger.length && !(await pathExists(targetPaths.ledgerFilePath))) {
1643
+ for (const entry of sessionLedger) {
1644
+ if (!appendLedgerLine(targetPaths.ledgerFilePath, entry, {
1645
+ maxBytes: persistenceOptions.ledgerMaxBytes,
1646
+ retentionFiles: persistenceOptions.ledgerRetentionFiles,
1647
+ })) {
1648
+ throw new Error(`could not migrate the goal ledger for session ${sessionID}`)
1649
+ }
1650
+ }
1651
+ }
1652
+ await writeStateSnapshot(targetPaths.stateFilePath, payload)
1466
1653
  }
1467
- raw = await fs.readFile(path, "utf8")
1468
- } catch (error) {
1469
- if (error?.code === "ENOENT") {
1470
- await migrationLease?.release()
1471
- continue
1654
+
1655
+ await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1656
+ for (const sourcePath of [candidate.stateFilePath, candidate.ledgerFilePath]) {
1657
+ if (!(await pathExists(sourcePath))) continue
1658
+ const backupPath = `${sourcePath}.migrated.${Date.now()}.${randomUUID()}`
1659
+ try {
1660
+ await fs.rename(sourcePath, backupPath)
1661
+ } catch (error) {
1662
+ await logPluginError(client, `Could not retire migrated goal persistence at ${sourcePath}.`, error)
1663
+ }
1472
1664
  }
1473
- // A present-but-unreadable primary file should not be silently
1474
- // overwritten, so report it as invalid rather than missing.
1475
- await logPluginError(client, "Failed to load persisted goal state", error)
1476
- if (primary) return "invalid"
1477
- await migrationLease?.release()
1478
- continue
1665
+ return
1666
+ } finally {
1667
+ await migrationLease.release()
1479
1668
  }
1669
+ }
1480
1670
 
1481
- let status
1671
+ // A fresh project has no aggregate or legacy state. Mark the namespace so a
1672
+ // later session does not repeatedly probe global fallback paths.
1673
+ await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1674
+ }
1675
+
1676
+ async function loadPersistedSessionState(persistence, client, sessionID) {
1677
+ const state = await readPersistedStateFile(persistence.stateFilePath, client)
1678
+ if (state.status === "loaded") {
1679
+ await applyParsedStateFile(state.raw, client, sessionID)
1680
+ await reconcileLoadedStateWithLedger(persistence, client, sessionID)
1681
+ return "loaded"
1682
+ }
1683
+ const recovered = await reconstructFromLedger(persistence, client, sessionID)
1684
+ if (state.status === "invalid" && recovered === "reconstructed") {
1685
+ const quarantinePath = `${persistence.stateFilePath}.corrupt.${Date.now()}.${randomUUID()}`
1482
1686
  try {
1483
- status = await applyParsedStateFile(raw, client)
1687
+ await fs.rename(persistence.stateFilePath, quarantinePath)
1688
+ await logPluginError(
1689
+ client,
1690
+ `Preserved invalid persisted goal state at ${quarantinePath} before ledger recovery.`,
1691
+ )
1484
1692
  } catch (error) {
1485
- await logPluginError(client, "Failed to load persisted goal state", error)
1486
- if (primary) return recoverInvalidPrimary()
1487
- await migrationLease?.release()
1488
- continue
1489
- }
1490
-
1491
- if (status === "loaded") {
1492
- // Cross-check: the ledger is written before the state file for terminal
1493
- // events (completed, cleared). If the terminal persist succeeded in the
1494
- // ledger but the state file write failed (e.g. process killed between the
1495
- // two writes), the reloaded state may still have the goal as active. Remove
1496
- // any loaded active goals whose goalId has a terminal ledger entry.
1497
- await reconcileLoadedStateWithLedger(persistenceOptions, client)
1498
- if (primary) return "loaded"
1499
- persistenceOptions.migrationClaim = { path, lease: migrationLease }
1500
- currentRuntime().migrationLease = migrationLease
1501
- return "migrated"
1693
+ await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
1502
1694
  }
1503
- // status === "invalid": preserve a present-but-corrupt primary; for a
1504
- // fallback, keep trying the next candidate.
1505
- if (primary) return recoverInvalidPrimary()
1506
- await migrationLease?.release()
1507
1695
  }
1508
-
1509
- // No state file found at any candidate path → try reconstructing from the
1510
- // append-only ledger before giving up.
1511
- return reconstructFromLedger(persistenceOptions, client)
1696
+ return recovered
1512
1697
  }
1513
1698
 
1514
1699
  // Last-resort recovery: when the main state file is absent, rebuild still-active
1515
1700
  // goals from the append-only ledger so a lost/rotated state file does not drop
1516
1701
  // in-flight goals. Recovered goals are paused (via deserializeGoal).
1517
- async function reconstructFromLedger(persistenceOptions, client) {
1702
+ async function reconstructFromLedger(persistenceOptions, client, onlySessionID = null) {
1518
1703
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1519
1704
  maxBytes: persistenceOptions.ledgerMaxBytes,
1520
1705
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
1521
1706
  })
1522
1707
  if (!entries.length) return "missing"
1523
1708
 
1524
- const reconstructed = reconstructGoalsFromLedger(entries)
1709
+ const reconstructed = reconstructGoalsFromLedger(entries).filter(
1710
+ (goal) => !onlySessionID || goal.sessionID === onlySessionID,
1711
+ )
1525
1712
  if (!reconstructed.length) return "missing"
1526
1713
 
1527
- clearRuntimeState()
1714
+ if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1715
+ else clearRuntimeState()
1528
1716
  const focusCandidates = new Map()
1529
1717
  for (const stub of reconstructed) {
1530
1718
  const normalized = normalizePersistedGoal(stub)
@@ -1536,6 +1724,7 @@ async function reconstructFromLedger(persistenceOptions, client) {
1536
1724
  }
1537
1725
  }
1538
1726
  for (const [sessionID, goals] of sessionGoals.entries()) {
1727
+ if (onlySessionID && sessionID !== onlySessionID) continue
1539
1728
  const preferred = focusCandidates.get(sessionID)
1540
1729
  const focused = (preferred && goals.get(preferred)) || goals.values().next().value
1541
1730
  if (focused) focusGoal(sessionID, focused)
@@ -1547,55 +1736,46 @@ async function reconstructFromLedger(persistenceOptions, client) {
1547
1736
  return goalStates.size > 0 ? "reconstructed" : "missing"
1548
1737
  }
1549
1738
 
1550
- async function persistState(persistenceOptions, client) {
1551
- if (!persistenceOptions.persistState) return true
1552
-
1553
- const tmpPath = `${persistenceOptions.stateFilePath}.${process.pid}.${randomUUID()}.tmp`
1554
- try {
1555
- await fs.mkdir(dirname(persistenceOptions.stateFilePath), { recursive: true, mode: 0o700 })
1556
- await fs.writeFile(
1557
- tmpPath,
1558
- JSON.stringify(
1559
- {
1560
- version: STATE_FILE_VERSION,
1561
- // All live goals across sessions, each flagged whether it is the
1562
- // session's focused goal so focus survives a restart.
1563
- goals: [...sessionGoals.values()]
1564
- .flatMap((map) => [...map.values()])
1565
- .slice(-MAX_PERSISTED_ENTRIES)
1566
- .map((goal) => ({
1567
- ...serializeGoal(goal),
1568
- focused: goalStates.get(goal.sessionID)?.goalId === goal.goalId,
1569
- })),
1570
- results: [...lastGoalResults.entries()].slice(-MAX_PERSISTED_ENTRIES).map(([sessionID, result]) => ({
1739
+ function currentSessionStatePayload(sessionID) {
1740
+ return {
1741
+ version: STATE_FILE_VERSION,
1742
+ goals: (listSessionGoals(sessionID) || [])
1743
+ .slice(-MAX_LIVE_GOALS_PER_SESSION)
1744
+ .map((goal) => ({
1745
+ ...serializeGoal(goal),
1746
+ focused: goalStates.get(sessionID)?.goalId === goal.goalId,
1747
+ })),
1748
+ results: lastGoalResults.has(sessionID)
1749
+ ? [{
1750
+ ...lastGoalResults.get(sessionID),
1751
+ sessionID,
1752
+ history: [...(lastGoalResults.get(sessionID).history || [])],
1753
+ checkpoints: [...(lastGoalResults.get(sessionID).checkpoints || [])],
1754
+ lastCheckpoint: lastGoalResults.get(sessionID).lastCheckpoint || null,
1755
+ }]
1756
+ : [],
1757
+ archives: sessionArchive.has(sessionID)
1758
+ ? [{
1759
+ sessionID,
1760
+ results: sessionArchive.get(sessionID).map((result) => ({
1571
1761
  ...result,
1572
1762
  sessionID,
1573
1763
  history: [...(result.history || [])],
1574
1764
  checkpoints: [...(result.checkpoints || [])],
1575
1765
  lastCheckpoint: result.lastCheckpoint || null,
1576
1766
  })),
1577
- archives: [...sessionArchive.entries()].slice(-MAX_PERSISTED_ENTRIES).map(([sessionID, results]) => ({
1578
- sessionID,
1579
- results: results.map((result) => ({
1580
- ...result,
1581
- sessionID,
1582
- history: [...(result.history || [])],
1583
- checkpoints: [...(result.checkpoints || [])],
1584
- lastCheckpoint: result.lastCheckpoint || null,
1585
- })),
1586
- })),
1587
- orderedSessions: [...sessionOrdered].slice(-MAX_PERSISTED_ENTRIES),
1588
- },
1589
- null,
1590
- 2,
1591
- ),
1592
- { encoding: "utf8", mode: 0o600 },
1593
- )
1594
- await fs.rename(tmpPath, persistenceOptions.stateFilePath)
1595
- await fs.chmod(persistenceOptions.stateFilePath, 0o600)
1767
+ }]
1768
+ : [],
1769
+ orderedSessions: sessionOrdered.has(sessionID) ? [sessionID] : [],
1770
+ }
1771
+ }
1772
+
1773
+ async function persistState(persistence, client, sessionID) {
1774
+ if (!persistence.persistState) return true
1775
+ try {
1776
+ await writeStateSnapshot(persistence.stateFilePath, currentSessionStatePayload(sessionID))
1596
1777
  return true
1597
1778
  } catch (error) {
1598
- await fs.rm(tmpPath, { force: true }).catch(() => {})
1599
1779
  await logPluginError(client, "Failed to persist goal state", error)
1600
1780
  return false
1601
1781
  }
@@ -2339,10 +2519,6 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2339
2519
  return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
2340
2520
  if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
2341
2521
  return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
2342
- if (!goalStates.has(sessionID) && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
2343
- return `The plugin already tracks ${MAX_PERSISTED_ENTRIES} live goals; clear or complete one before creating another.`
2344
- }
2345
-
2346
2522
  const options = normalizeOptions({
2347
2523
  ...defaultGoalOptions,
2348
2524
  ...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
@@ -2368,7 +2544,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2368
2544
  lastGoalResults.delete(sessionID)
2369
2545
  registerSessionGoal(goal)
2370
2546
  focusGoal(sessionID, goal)
2371
- await persist()
2547
+ await persist(sessionID)
2372
2548
  // Escape in the tool result only: goal.condition is stored raw so callers
2373
2549
  // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
2374
2550
  // themselves. Escaping here prevents XML metacharacters in user-supplied
@@ -2450,7 +2626,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2450
2626
  goal.stopReason = "audit rejected"
2451
2627
  goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
2452
2628
  pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
2453
- await persist()
2629
+ await persist(sessionID)
2454
2630
  return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
2455
2631
  }
2456
2632
  }
@@ -2463,9 +2639,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2463
2639
  const ordered = sessionOrdered.has(sessionID)
2464
2640
  rememberGoalResult(sessionID, goal, "achieved", "", evidence)
2465
2641
  cleanupGoal(sessionID)
2466
- // Advance an ordered (sisyphus) sequence just like the marker path does.
2642
+ // Advance an ordered sequence just like the marker path does.
2467
2643
  if (ordered) promoteNextOrderedGoal(sessionID)
2468
- const durable = await persistFinal("completion", ledgerDurable)
2644
+ const durable = await persistFinal(sessionID, "completion", ledgerDurable)
2469
2645
  if (durable === false) {
2470
2646
  restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
2471
2647
  return "Completion verified, but terminal state could not be persisted. Goal remains paused."
@@ -2509,7 +2685,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2509
2685
  if (!messages.length) {
2510
2686
  return "Nothing to update. Provide `objective` and/or `status`."
2511
2687
  }
2512
- await persist()
2688
+ await persist(sessionID)
2513
2689
  return messages.join(" ")
2514
2690
  }
2515
2691
 
@@ -2525,7 +2701,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2525
2701
  sessionGoals.delete(sessionID)
2526
2702
  cleanupGoal(sessionID)
2527
2703
  lastGoalResults.delete(sessionID)
2528
- await persistFinal("clear")
2704
+ await persistFinal(sessionID, "clear")
2529
2705
  return "Goal cleared."
2530
2706
  }
2531
2707
 
@@ -2550,11 +2726,12 @@ async function loadOpencodePluginModule() {
2550
2726
  return opencodePluginModulePromise
2551
2727
  }
2552
2728
 
2553
- function buildAgentTools(toolHelper, handlers) {
2729
+ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
2554
2730
  const schema = toolHelper.schema
2555
2731
  const run = (handler) => async (args, ctx) => {
2556
2732
  const sessionID = agentToolSessionID(ctx)
2557
2733
  if (!sessionID) return "No session id available for the goal tool."
2734
+ await ensureSessionLoaded(sessionID)
2558
2735
  return handler(sessionID, args || {})
2559
2736
  }
2560
2737
  // Canonical tools use a small, versioned machine-readable envelope. Keep the
@@ -2568,6 +2745,7 @@ function buildAgentTools(toolHelper, handlers) {
2568
2745
  goalToolFailure("missing_session", "No session id available for the goal tool."),
2569
2746
  )
2570
2747
  }
2748
+ await ensureSessionLoaded(sessionID)
2571
2749
  return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
2572
2750
  }
2573
2751
 
@@ -2707,7 +2885,7 @@ function formatGoalList(sessionID, commandName = "goal") {
2707
2885
 
2708
2886
  const lines = []
2709
2887
  if (goals.length) {
2710
- lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered (sisyphus)" : ""}:`)
2888
+ lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
2711
2889
  goals.forEach((goal, index) => {
2712
2890
  const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
2713
2891
  const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
@@ -2899,30 +3077,67 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2899
3077
  env: pluginOptions.env,
2900
3078
  cwd: pluginOptions.cwd || directory,
2901
3079
  })
2902
- if (persistenceOptions.persistState) {
2903
- await assertSafeProjectPersistencePath(persistenceOptions)
2904
- currentRuntime().persistenceLease = await acquirePersistenceLease(persistenceOptions.stateFilePath)
2905
- }
2906
3080
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
2907
- // Serialize all persist() calls through a promise chain so concurrent callers
2908
- // never race on the temp-file rename. persistState returns a boolean and never
2909
- // rejects, so the chain cannot stall on a thrown error.
2910
- let persistChain = Promise.resolve(true)
2911
- const persist = () => {
2912
- if (runtime.disposed) return Promise.resolve(false)
2913
- persistChain = persistChain
3081
+
3082
+ // Each session owns an independent snapshot, ledger, write chain, and
3083
+ // lifetime lease. A project can therefore host any number of unrelated goal
3084
+ // sessions without allowing two processes to drive the same session.
3085
+ const persist = (sessionID) => {
3086
+ const persistence = runtime.sessionPersistence.get(sessionID)
3087
+ if (runtime.disposed || !persistence) return Promise.resolve(false)
3088
+ persistence.persistChain = persistence.persistChain
2914
3089
  .catch(() => false)
2915
- .then(() => persistState(persistenceOptions, client))
2916
- return persistChain
3090
+ .then(() => persistState(persistence, client, sessionID))
3091
+ return persistence.persistChain
3092
+ }
3093
+
3094
+ const ensureSessionLoaded = async (sessionID) => {
3095
+ if (!persistenceOptions.persistState || !sessionID) return true
3096
+ const existingLoad = runtime.sessionLoadPromises.get(sessionID)
3097
+ if (existingLoad) return existingLoad
3098
+ if (runtime.sessionPersistence.has(sessionID)) return true
3099
+
3100
+ const load = (async () => {
3101
+ const paths = sessionPathsFor(persistenceOptions, sessionID)
3102
+ await assertSafeProjectPersistencePath({
3103
+ ...persistenceOptions,
3104
+ stateFilePath: paths.stateFilePath,
3105
+ })
3106
+ const lease = await acquirePersistenceLease(paths.stateFilePath)
3107
+ const persistence = {
3108
+ ...persistenceOptions,
3109
+ ...paths,
3110
+ persistChain: Promise.resolve(true),
3111
+ lease,
3112
+ }
3113
+ runtime.sessionPersistence.set(sessionID, persistence)
3114
+ try {
3115
+ await migrateLegacyState(persistenceOptions, client)
3116
+ const status = await loadPersistedSessionState(persistence, client, sessionID)
3117
+ pruneGoalResults(defaultGoalOptions)
3118
+ if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID)
3119
+ return true
3120
+ } catch (error) {
3121
+ runtime.sessionPersistence.delete(sessionID)
3122
+ await lease.release().catch(() => false)
3123
+ throw error
3124
+ }
3125
+ })()
3126
+
3127
+ runtime.sessionLoadPromises.set(sessionID, load)
3128
+ try {
3129
+ return await load
3130
+ } finally {
3131
+ runtime.sessionLoadPromises.delete(sessionID)
3132
+ }
2917
3133
  }
2918
- runtime.drainPersistence = () => persistChain.catch(() => false)
2919
3134
 
2920
3135
  // Fail closed when persisting a terminal state (complete/blocked)
2921
3136
  // fails, surface it loudly. The terminal event is already in the append-only
2922
3137
  // ledger, so it stays recoverable across a restart even though the main state
2923
3138
  // file write did not land.
2924
- const persistTerminalState = async (label, ledgerDurable = false) => {
2925
- const stateDurable = await persist()
3139
+ const persistTerminalState = async (sessionID, label, ledgerDurable = false) => {
3140
+ const stateDurable = await persist(sessionID)
2926
3141
  if (!stateDurable && persistenceOptions.persistState) {
2927
3142
  await logPluginError(
2928
3143
  client,
@@ -2936,10 +3151,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2936
3151
 
2937
3152
  // Route lifecycle events to the JSONL ledger only when persistence is on.
2938
3153
  if (persistenceOptions.persistState) {
2939
- setLedgerSink((entry) => appendLedgerLine(persistenceOptions.ledgerFilePath, entry, {
2940
- maxBytes: persistenceOptions.ledgerMaxBytes,
2941
- retentionFiles: persistenceOptions.ledgerRetentionFiles,
2942
- }))
3154
+ setLedgerSink((entry) => {
3155
+ const persistence = runtime.sessionPersistence.get(entry.sessionID)
3156
+ if (!persistence) return false
3157
+ return appendLedgerLine(persistence.ledgerFilePath, entry, {
3158
+ maxBytes: persistence.ledgerMaxBytes,
3159
+ retentionFiles: persistence.ledgerRetentionFiles,
3160
+ })
3161
+ })
2943
3162
  } else {
2944
3163
  setLedgerSink(null)
2945
3164
  }
@@ -2982,34 +3201,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2982
3201
  : null
2983
3202
 
2984
3203
  clearRuntimeState()
2985
- const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
2986
- pruneGoalResults(defaultGoalOptions)
2987
- // "migrated" = loaded from a legacy/XDG fallback path; "reconstructed" =
2988
- // rebuilt from the ledger. Both persist forward to the resolved path.
2989
- if (
2990
- persistedStateStatus === "loaded" ||
2991
- persistedStateStatus === "missing" ||
2992
- persistedStateStatus === "migrated" ||
2993
- persistedStateStatus === "reconstructed"
2994
- ) {
2995
- const initialPersisted = await persist()
2996
- if (persistedStateStatus === "migrated" && persistenceOptions.migrationClaim) {
2997
- const { path, lease } = persistenceOptions.migrationClaim
2998
- if (initialPersisted) {
2999
- const backupPath = `${path}.migrated.${Date.now()}.${randomUUID()}`
3000
- try {
3001
- await fs.rename(path, backupPath)
3002
- } catch (error) {
3003
- await logPluginError(client, `Could not retire migrated legacy goal state at ${path}.`, error)
3004
- }
3005
- }
3006
- await lease.release()
3007
- runtime.migrationLease = null
3008
- persistenceOptions.migrationClaim = null
3009
- }
3010
- }
3011
3204
 
3012
- const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
3205
+ const agentToolHandlers = buildAgentToolHandlers({
3206
+ defaultGoalOptions,
3207
+ persist,
3208
+ persistTerminalState,
3209
+ completionAuditor,
3210
+ commandName,
3211
+ })
3013
3212
 
3014
3213
  const abortAcceptedContinuation = async (sessionID) => {
3015
3214
  const runtimeState = currentRuntime()
@@ -3040,7 +3239,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3040
3239
  goal.continuationClaim = null
3041
3240
  pushHistory(goal, "paused", history)
3042
3241
  activeContinues.delete(sessionID)
3043
- await persist()
3242
+ await persist(sessionID)
3044
3243
  if (abortAccepted) await abortAcceptedContinuation(sessionID)
3045
3244
  return true
3046
3245
  }
@@ -3110,7 +3309,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3110
3309
  }
3111
3310
 
3112
3311
  goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
3113
- const claimPersisted = await persist()
3312
+ const claimPersisted = await persist(sessionID)
3114
3313
  if (!claimPersisted && persistenceOptions.persistState) {
3115
3314
  goal.continuationClaim = null
3116
3315
  goal.stopped = true
@@ -3132,6 +3331,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3132
3331
  },
3133
3332
  "chat.params": async (input) => {
3134
3333
  if (!input?.sessionID) return
3334
+ await ensureSessionLoaded(input.sessionID)
3135
3335
  const context = normalizeExecutionContext({
3136
3336
  agent: input.agent,
3137
3337
  model: input.model,
@@ -3142,6 +3342,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3142
3342
  "chat.message": async (input, output) => {
3143
3343
  const sessionID = input?.sessionID
3144
3344
  if (!sessionID) return
3345
+ await ensureSessionLoaded(sessionID)
3145
3346
  const context = normalizeExecutionContext(input)
3146
3347
  if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3147
3348
 
@@ -3162,7 +3363,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3162
3363
  },
3163
3364
  "tool.execute.before": async (input) => {
3164
3365
  const sessionID = input?.sessionID
3165
- if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
3366
+ if (!sessionID) return
3367
+ await ensureSessionLoaded(sessionID)
3368
+ if (!currentRuntime().readOnlyCommandGuards.has(sessionID)) return
3166
3369
  if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
3167
3370
  throw new Error(
3168
3371
  `This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`,
@@ -3181,6 +3384,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3181
3384
  }
3182
3385
  const args = input.arguments.trim()
3183
3386
  const sessionID = input.sessionID
3387
+ await ensureSessionLoaded(sessionID)
3184
3388
  currentRuntime().readOnlyCommandGuards.delete(sessionID)
3185
3389
  pruneGoalResults(defaultGoalOptions)
3186
3390
 
@@ -3243,7 +3447,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3243
3447
  sessionGoals.delete(sessionID)
3244
3448
  cleanupGoal(sessionID)
3245
3449
  lastGoalResults.delete(sessionID)
3246
- await persist()
3450
+ await persist(sessionID)
3247
3451
  output.parts = [makeTextPart("Goal cleared.")]
3248
3452
  return
3249
3453
  }
@@ -3262,7 +3466,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3262
3466
  goal.continuationClaim = null
3263
3467
  activeContinues.delete(sessionID)
3264
3468
  pushHistory(goal, "paused", "User paused the active goal.")
3265
- await persist()
3469
+ await persist(sessionID)
3266
3470
  await abortAcceptedContinuation(sessionID)
3267
3471
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
3268
3472
  return
@@ -3288,7 +3492,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3288
3492
  goal.blockedReason = ""
3289
3493
  goal.lastStatus = "Goal resumed with a fresh local budget."
3290
3494
  pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
3291
- await persist()
3495
+ await persist(sessionID)
3292
3496
  output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
3293
3497
  return
3294
3498
  }
@@ -3328,7 +3532,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3328
3532
  goal.continuationClaim = null
3329
3533
  goal.lastStatus = "Goal objective updated."
3330
3534
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3331
- await persist()
3535
+ await persist(sessionID)
3332
3536
  output.parts = [
3333
3537
  makeTextPart(
3334
3538
  [
@@ -3347,8 +3551,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3347
3551
  return
3348
3552
  }
3349
3553
 
3350
- if (args === "sisyphus" || args.toLowerCase().startsWith("sisyphus ")) {
3351
- const rest = args.slice("sisyphus".length).trim()
3554
+ const sequenceCommand = SEQUENCE_COMMANDS.find(
3555
+ (command) => args.toLowerCase() === command || args.toLowerCase().startsWith(`${command} `),
3556
+ )
3557
+ if (sequenceCommand) {
3558
+ const rest = args.slice(sequenceCommand.length).trim()
3352
3559
  const objectives = rest
3353
3560
  .split(/\n|;/)
3354
3561
  .map((part) => stripWrappingQuotes(part.trim()))
@@ -3356,7 +3563,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3356
3563
  if (!objectives.length) {
3357
3564
  output.parts = [
3358
3565
  makeTextPart(
3359
- `No objectives provided. Use \`/${commandName} sisyphus <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
3566
+ `No objectives provided. Use \`/${commandName} sequence <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
3360
3567
  ),
3361
3568
  ]
3362
3569
  return
@@ -3365,11 +3572,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3365
3572
  output.parts = [makeTextPart(`An ordered sequence may contain at most ${MAX_LIVE_GOALS_PER_SESSION} goals.`)]
3366
3573
  return
3367
3574
  }
3368
- const existingCount = listSessionGoals(sessionID).length
3369
- if (totalLiveGoals() - existingCount + objectives.length > MAX_PERSISTED_ENTRIES) {
3370
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3371
- return
3372
- }
3373
3575
  if (objectives.some((objective) => objective.length > MAX_GOAL_OBJECTIVE_LENGTH)) {
3374
3576
  output.parts = [makeTextPart(`Each goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
3375
3577
  return
@@ -3400,17 +3602,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3400
3602
  pushHistory(
3401
3603
  created,
3402
3604
  "set",
3403
- `Ordered goal ${index + 1}/${objectives.length} created (sisyphus sequence).`,
3605
+ `Ordered goal ${index + 1}/${objectives.length} created.`,
3404
3606
  )
3405
3607
  registerSessionGoal(created)
3406
3608
  })
3407
3609
  focusGoal(sessionID, firstGoal)
3408
3610
  sessionOrdered.add(sessionID)
3409
- await persist()
3611
+ await persist(sessionID)
3410
3612
  output.parts = [
3411
3613
  makeTextPart(
3412
3614
  [
3413
- `Started an ordered sequence of ${objectives.length} goal(s) (sisyphus mode):`,
3615
+ `Started an ordered sequence of ${objectives.length} goal(s):`,
3414
3616
  ...objectives.map((objective, index) => `${index + 1}. ${objective}`),
3415
3617
  "",
3416
3618
  `Focused goal 1: ${firstGoal.condition}`,
@@ -3465,7 +3667,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3465
3667
  resumeGoalClock(target)
3466
3668
  pushHistory(target, "focused", "Brought into focus as the session's active goal.")
3467
3669
  focusGoal(sessionID, target)
3468
- await persist()
3670
+ await persist(sessionID)
3469
3671
  output.parts = [
3470
3672
  makeTextPart(
3471
3673
  [
@@ -3505,10 +3707,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3505
3707
  output.parts = [makeTextPart(`A session may contain at most ${MAX_LIVE_GOALS_PER_SESSION} live goals.`)]
3506
3708
  return
3507
3709
  }
3508
- if (totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
3509
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3510
- return
3511
- }
3512
3710
  // Keep the current goal (background it) and focus a new one.
3513
3711
  const current = goalStates.get(sessionID)
3514
3712
  if (current) {
@@ -3525,7 +3723,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3525
3723
  )
3526
3724
  registerSessionGoal(added)
3527
3725
  focusGoal(sessionID, added)
3528
- await persist()
3726
+ await persist(sessionID)
3529
3727
  const total = listSessionGoals(sessionID).length
3530
3728
  output.parts = [
3531
3729
  makeTextPart(
@@ -3545,10 +3743,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3545
3743
  }
3546
3744
 
3547
3745
  const replacedGoal = goalStates.get(sessionID)
3548
- if (!replacedGoal && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
3549
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3550
- return
3551
- }
3552
3746
  const goal = buildGoalState(sessionID, parsed.condition, parsed.options, parsed.meta)
3553
3747
 
3554
3748
  pushHistory(
@@ -3560,14 +3754,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3560
3754
  // Replace the focused goal (cleanupGoal discards it); backgrounded goals
3561
3755
  // for this session are preserved. Use `/goal add` to keep the current
3562
3756
  // goal and add another. Clear any ordered-sequence flag so the new
3563
- // standalone goal does not trigger sisyphus auto-promotion of old sequence
3757
+ // standalone goal does not trigger auto-promotion of the old sequence
3564
3758
  // goals that may still be in the registry (matches the agent setGoal path).
3565
3759
  sessionOrdered.delete(sessionID)
3566
3760
  cleanupGoal(sessionID)
3567
3761
  lastGoalResults.delete(sessionID)
3568
3762
  registerSessionGoal(goal)
3569
3763
  focusGoal(sessionID, goal)
3570
- await persist()
3764
+ await persist(sessionID)
3571
3765
  output.parts = [
3572
3766
  makeTextPart(
3573
3767
  [
@@ -3599,6 +3793,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3599
3793
  },
3600
3794
 
3601
3795
  event: async ({ event }) => {
3796
+ const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
3797
+ if (eventSessionID) await ensureSessionLoaded(eventSessionID)
3798
+
3602
3799
  if (event?.type === "session.status") {
3603
3800
  const sessionID = getSessionID(event)
3604
3801
  const status = event?.properties?.status?.type || event?.data?.status?.type
@@ -3635,7 +3832,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3635
3832
  if (!goal) return
3636
3833
  goal.messageIDs = new Set()
3637
3834
  goal.totalTokens = 0
3638
- await persist()
3835
+ await persist(sessionID)
3639
3836
  return
3640
3837
  }
3641
3838
 
@@ -3694,7 +3891,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3694
3891
  changed = true
3695
3892
  }
3696
3893
 
3697
- if (changed) await persist()
3894
+ if (changed) await persist(messageSessionID(message))
3698
3895
  return
3699
3896
  }
3700
3897
 
@@ -3829,7 +4026,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3829
4026
  auditedGoal.stopReason = "audit rejected"
3830
4027
  auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
3831
4028
  pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
3832
- await persist()
4029
+ await persist(sessionID)
3833
4030
  await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
3834
4031
  return
3835
4032
  }
@@ -3852,12 +4049,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3852
4049
  const ordered = sessionOrdered.has(sessionID)
3853
4050
  rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
3854
4051
  cleanupGoal(sessionID)
3855
- // Ordered (sisyphus) sequence: auto-promote the next goal so the
4052
+ // Ordered sequence: auto-promote the next goal so the
3856
4053
  // session keeps working through the sequence without manual /goal focus.
3857
4054
  if (ordered) {
3858
4055
  promoteNextOrderedGoal(sessionID)
3859
4056
  }
3860
- const durable = await persistTerminalState("completion", ledgerDurable)
4057
+ const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
3861
4058
  if (durable === false) {
3862
4059
  restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
3863
4060
  await announceAudit(
@@ -3891,7 +4088,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3891
4088
  blockedGoal.stopped = true
3892
4089
  blockedGoal.stopReason = "blocked"
3893
4090
  const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
3894
- const durable = await persistTerminalState("blocked", ledgerDurable)
4091
+ const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
3895
4092
  if (durable === false) {
3896
4093
  blockedGoal.stopReason = "terminal persistence failed"
3897
4094
  blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
@@ -3931,7 +4128,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3931
4128
  claimedGoal.stopReason = limitReason
3932
4129
  claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
3933
4130
  pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
3934
- await persist()
4131
+ await persist(sessionID)
3935
4132
  currentRuntime().promptInFlightSessions.add(sessionID)
3936
4133
  let response
3937
4134
  try {
@@ -3952,7 +4149,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3952
4149
  activeGoalAfterMessages.lastStatus = limitReason
3953
4150
  pushHistory(activeGoalAfterMessages, "limit", limitReason)
3954
4151
  }
3955
- await persist()
4152
+ await persist(sessionID)
3956
4153
  return
3957
4154
  }
3958
4155
 
@@ -4007,7 +4204,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4007
4204
  "paused",
4008
4205
  `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
4009
4206
  )
4010
- await persist()
4207
+ await persist(sessionID)
4011
4208
  return
4012
4209
  }
4013
4210
 
@@ -4052,7 +4249,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4052
4249
  "paused",
4053
4250
  `Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
4054
4251
  )
4055
- await persist()
4252
+ await persist(sessionID)
4056
4253
  return
4057
4254
  }
4058
4255
 
@@ -4101,7 +4298,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4101
4298
  // promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
4102
4299
  // the hard-limit path which also persists before its promptAsync call.
4103
4300
  pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
4104
- await persist()
4301
+ await persist(sessionID)
4105
4302
  }
4106
4303
 
4107
4304
  activeGoalBeforePrompt.turnCount += 1
@@ -4141,7 +4338,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4141
4338
  "paused",
4142
4339
  `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
4143
4340
  )
4144
- await persist()
4341
+ await persist(sessionID)
4145
4342
  return
4146
4343
  }
4147
4344
  }
@@ -4202,7 +4399,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4202
4399
  )
4203
4400
  }
4204
4401
  }
4205
- await persist()
4402
+ await persist(sessionID)
4206
4403
  } catch (error) {
4207
4404
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
4208
4405
  if (activeGoalAfterError) {
@@ -4222,7 +4419,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4222
4419
  activeGoalAfterError.stopReason = "auto-continue failures"
4223
4420
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
4224
4421
  }
4225
- await persist()
4422
+ await persist(sessionID)
4226
4423
  }
4227
4424
  await logPluginError(client, "Auto-continue failed", error)
4228
4425
  } finally {
@@ -4239,6 +4436,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4239
4436
 
4240
4437
  "experimental.chat.system.transform": async (input, output) => {
4241
4438
  if (!input.sessionID) return
4439
+ await ensureSessionLoaded(input.sessionID)
4242
4440
 
4243
4441
  const goal = goalStates.get(input.sessionID)
4244
4442
  if (!goal) return
@@ -4288,6 +4486,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4288
4486
 
4289
4487
  "experimental.session.compacting": async (input, output) => {
4290
4488
  if (!input?.sessionID || !output) return
4489
+ await ensureSessionLoaded(input.sessionID)
4291
4490
  const goal = goalStates.get(input.sessionID)
4292
4491
  if (!goal) return
4293
4492
  const context = buildCompactionContext(goal)
@@ -4307,6 +4506,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4307
4506
  // auto-continue to avoid two continuations racing after a compaction.
4308
4507
  // Paused/stopped goals leave the native behavior untouched.
4309
4508
  if (!input?.sessionID || !output) return
4509
+ await ensureSessionLoaded(input.sessionID)
4310
4510
  const goal = goalStates.get(input.sessionID)
4311
4511
  if (!goal || goal.stopped) return
4312
4512
  output.enabled = false
@@ -4328,7 +4528,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4328
4528
  const toolModule = await loadOpencodePluginModule()
4329
4529
  if (toolModule?.tool?.schema) {
4330
4530
  try {
4331
- hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers)
4531
+ hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers, ensureSessionLoaded)
4332
4532
  } catch (error) {
4333
4533
  await logPluginError(client, "Failed to register goal agent tools", error)
4334
4534
  }
@@ -4370,13 +4570,17 @@ function bindHooksToRuntime(hooks, runtime) {
4370
4570
  if (runtime.disposed) return
4371
4571
  runtime.disposed = true
4372
4572
  for (const controller of runtime.continuationControllers.values()) controller.abort()
4373
- await runtime.drainPersistence?.()
4573
+ await Promise.allSettled([...runtime.sessionLoadPromises.values()])
4574
+ for (const persistence of runtime.sessionPersistence.values()) {
4575
+ await persistence.persistChain.catch(() => false)
4576
+ }
4374
4577
  clearRuntimeState()
4375
4578
  setLedgerSink(null)
4376
- await runtime.persistenceLease?.release()
4377
- runtime.persistenceLease = null
4378
- await runtime.migrationLease?.release()
4379
- runtime.migrationLease = null
4579
+ for (const persistence of runtime.sessionPersistence.values()) {
4580
+ await persistence.lease?.release().catch(() => false)
4581
+ }
4582
+ runtime.sessionPersistence.clear()
4583
+ runtime.sessionLoadPromises.clear()
4380
4584
  })
4381
4585
  return bound
4382
4586
  }
@@ -4390,11 +4594,13 @@ export const GoalPlugin = async (context = {}, pluginOptions = {}) => {
4390
4594
  return bindHooksToRuntime(hooks, runtime)
4391
4595
  } catch (error) {
4392
4596
  runtime.disposed = true
4393
- await runtime.drainPersistence?.()
4394
- await runtime.persistenceLease?.release().catch(() => false)
4395
- runtime.persistenceLease = null
4396
- await runtime.migrationLease?.release().catch(() => false)
4397
- runtime.migrationLease = null
4597
+ await Promise.allSettled([...runtime.sessionLoadPromises.values()])
4598
+ for (const persistence of runtime.sessionPersistence.values()) {
4599
+ await persistence.persistChain.catch(() => false)
4600
+ await persistence.lease?.release().catch(() => false)
4601
+ }
4602
+ runtime.sessionPersistence.clear()
4603
+ runtime.sessionLoadPromises.clear()
4398
4604
  throw error
4399
4605
  }
4400
4606
  })
@@ -4451,6 +4657,7 @@ export const testInternals = {
4451
4657
  normalizeMessageUsage,
4452
4658
  normalizeUsage,
4453
4659
  normalizePersistenceOptions,
4660
+ sessionPathsFor,
4454
4661
  userInterventionDetected,
4455
4662
  outputTokensForMessage,
4456
4663
  parseGoalArguments,