opencode-goal-plugin 0.6.5 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.6 — 2026-07-29
4
+
5
+ - Shard persisted goal state and lifecycle ledgers by hashed OpenCode session ID so independent sessions can run concurrently in one project. Keep same-session single-writer leases, lazy session loading, crash recovery, and safe migration of aggregate, legacy, and XDG state.
6
+
3
7
  ## 0.6.5 — 2026-07-12
4
8
 
5
9
  - Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior.
package/README.md CHANGED
@@ -239,7 +239,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
239
239
 
240
240
  **Wrap-up vs. hard stop.** When a limit is reached, the plugin sends one final prompt asking the assistant to summarize what is done, what remains, and the next concrete step — rather than stopping silently. Use `/goal resume` to continue after any stop, including limit stops and no-progress pauses.
241
241
 
242
- Goal state is persisted by default to a **project-local** path, `.opencode/goals/state.json` relative to the working directory, so goals follow the project rather than your home directory. It is only a local workflow checkpoint and is not synchronized across machines or OpenCode instances. You may want to add `.opencode/goals/` to your `.gitignore`.
242
+ Goal state is persisted by default to a **project-local** namespace rooted at `.opencode/goals/state.json` relative to the working directory, so goals follow the project rather than your home directory. Each OpenCode session gets a separate hashed shard at `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`, allowing unrelated sessions in the same project to run concurrently. The state is local and is not synchronized across machines. You may want to add `.opencode/goals/` to your `.gitignore`.
243
243
 
244
244
  The state-file location is resolved with this precedence:
245
245
 
@@ -247,11 +247,11 @@ The state-file location is resolved with this precedence:
247
247
  2. the `OPENCODE_GOAL_STATE_PATH` environment variable, if set;
248
248
  3. the project-local default `<cwd>/.opencode/goals/state.json`.
249
249
 
250
- When the default path has no state yet, the plugin migrates forward from older locations on first load: the legacy `~/.opencode-goal-plugin/state.json` and the XDG path `${XDG_STATE_HOME:-~/.local/state}/opencode-goal-plugin/state.json`. Migration is exclusively claimed; after the project-local write succeeds, the legacy file is retired to a timestamped `.migrated…` backup so another project cannot import the same private goal state. An explicit `stateFilePath` or `OPENCODE_GOAL_STATE_PATH` is used literally with no migration fallback.
250
+ When a project has no shard namespace yet, the plugin migrates all sessions from older locations on first session access: the legacy `~/.opencode-goal-plugin/state.json` and the XDG path `${XDG_STATE_HOME:-~/.local/state}/opencode-goal-plugin/state.json`. Migration is exclusively claimed; after every session shard is written, the source files are retired to timestamped `.migrated…` backups so another project cannot import the same private goal state. An explicit `stateFilePath` or `OPENCODE_GOAL_STATE_PATH` is used as the shard namespace root and has no migration fallback.
251
251
 
252
252
  The state directory is created with owner-only permissions, and the JSON state file is written as `0600` because it may contain goal text, assistant checkpoints, and workflow history.
253
253
 
254
- Alongside the state file the plugin keeps an **append-only lifecycle ledger** (`<stateFile>.ledger.jsonl`, also `0600`). Every lifecycle event — set, edit, auto-continue, pause, resume, blocked, completed, limit — is appended as one JSON line. Because the in-memory history is capped, the ledger is the durable record: if the main state file is missing or corrupted, the plugin reconstructs still-active (non-completed) goals from the ledger on startup and reloads them in the paused recovery state. Terminal events (complete/blocked) are written to the ledger *before* the main state write, so a goal's terminal outcome survives even if that write fails (**fail-closed**); such a failure is logged at error level.
254
+ Alongside each session shard the plugin keeps an **append-only lifecycle ledger** (`<shard>/state.json.ledger.jsonl`, also `0600`). Every lifecycle event — set, edit, auto-continue, pause, resume, blocked, completed, limit — is appended as one JSON line. Because the in-memory history is capped, the ledger is the durable record: if a session state file is missing or corrupted, the plugin reconstructs still-active (non-completed) goals from that session's ledger on startup and reloads them in the paused recovery state. Terminal events (complete/blocked) are written to the ledger *before* the state write, so a goal's terminal outcome survives even if that write fails (**fail-closed**); such a failure is logged at error level.
255
255
 
256
256
  Recovered active goals are loaded in a **paused** state with a recovery note, so unattended auto-continue does not resume blindly after a restart. Set `"persistState": false` to keep purely in-memory behavior (this also disables the ledger).
257
257
 
@@ -328,7 +328,7 @@ Additional plugin-level options:
328
328
  - `goalAgentName` / `verifierAgentName` — customize the registered native agent names (defaults `goal` and `goal-verify`). The verifier is a hidden subagent with a default-deny tool policy; only `read`, `glob`, and `grep` are allowed.
329
329
  - `sdkShape` — OpenCode session-client argument shape: `legacy` (the default generated `PluginInput` client using `{ path, body, query }`) or `flat` (clients using `{ sessionID, ... }`). Read-only `messages`/`get` calls may probe the alternate shape after an argument/schema `TypeError`; mutating calls are never replayed, so set this option correctly for embedded clients.
330
330
  - `persistState` — whether to persist active goals and recent goal results to disk.
331
- - `stateFilePath` — where the persisted state JSON is written. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default is `<cwd>/.opencode/goals/state.json` (see the persistence section above), and `OPENCODE_GOAL_STATE_PATH` can override it without editing config.
331
+ - `stateFilePath` — root path for the persisted session-shard namespace. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default root is `<cwd>/.opencode/goals/state.json`; shards are written below `<stateFilePath>.sessions/` (see the persistence section above).
332
332
  - `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling.
333
333
  - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
334
334
  - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
@@ -389,7 +389,7 @@ OpenCode's current `command.execute.before` hook does not fully intercept comman
389
389
 
390
390
  The plugin depends on `experimental.chat.system.transform` and other OpenCode plugin hooks that may change between OpenCode versions.
391
391
 
392
- Only one persistence-enabled OpenCode plugin instance may own a given `stateFilePath` at a time. A second process fails initialization with the owning PID/host instead of risking last-writer-wins state loss. Dispose the first host or configure a different path.
392
+ Distinct OpenCode sessions may own shards under the same `stateFilePath` concurrently. A second process using the same session shard is still rejected with the owning PID/host instead of risking last-writer-wins state loss; use the original session or wait for its owner to dispose.
393
393
 
394
394
  ## Diagnostics and recovery
395
395
 
@@ -10,6 +10,8 @@ The latest published release is the supported line. Public compatibility covers:
10
10
  - the documented OpenCode hook names
11
11
  - the six canonical goal tools and five legacy tool aliases
12
12
  - persisted-state recovery from versions documented in the changelog
13
+ - concurrent persistence for distinct OpenCode sessions in one project, with
14
+ single-writer protection retained per session
13
15
 
14
16
  The package requires Node.js 18 or newer and OpenCode 1.17.15 through the latest
15
17
  compatible 1.x release. CI runs the complete unit suite on Node 18, 20, 22, and
package/index.d.ts CHANGED
@@ -222,17 +222,19 @@ export interface GoalPluginOptions {
222
222
  persistState?: boolean
223
223
 
224
224
  /**
225
- * Filesystem path where persisted goal state is written when
226
- * `persistState` is enabled. Overrides both the project-local default
227
- * and the `OPENCODE_GOAL_STATE_PATH` environment variable.
225
+ * Root filesystem path for persisted session-shard state when
226
+ * `persistState` is enabled. Each session is written below
227
+ * `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`. Overrides both
228
+ * the project-local default and the `OPENCODE_GOAL_STATE_PATH` environment
229
+ * variable.
228
230
  * @default "<cwd>/.opencode/goals/state.json"
229
231
  */
230
232
  stateFilePath?: string
231
233
 
232
234
  /**
233
- * Filesystem path for the append-only lifecycle ledger
234
- * (`<event> per line`, used to reconstruct active goals if the main
235
- * state file is missing or corrupted).
235
+ * Legacy aggregate filesystem path for the append-only lifecycle ledger.
236
+ * During migration, events are partitioned into per-session shard ledgers;
237
+ * new writes use `<shard>/state.json.ledger.jsonl`.
236
238
  * @default "<stateFilePath>.ledger.jsonl"
237
239
  */
238
240
  ledgerFilePath?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -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,
@@ -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
  }
@@ -684,12 +685,6 @@ function listSessionGoals(sessionID) {
684
685
  return map ? [...map.values()] : []
685
686
  }
686
687
 
687
- function totalLiveGoals() {
688
- let total = 0
689
- for (const goals of sessionGoals.values()) total += goals.size
690
- return total
691
- }
692
-
693
688
  function rememberMessageID(goal, messageID) {
694
689
  goal.messageIDs.add(messageID)
695
690
  while (goal.messageIDs.size > MAX_MESSAGE_IDS_PER_GOAL) {
@@ -789,6 +784,29 @@ function clearRuntimeState() {
789
784
  runtime.readOnlyCommandGuards.clear()
790
785
  }
791
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
+
792
810
  function pruneGoalResults(options) {
793
811
  const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
794
812
  const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
@@ -997,6 +1015,26 @@ function ledgerPathFor(stateFilePath) {
997
1015
  return `${stateFilePath}.ledger.jsonl`
998
1016
  }
999
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
+
1000
1038
  // XDG-style state path: $XDG_STATE_HOME/opencode-goal-plugin/state.json,
1001
1039
  // defaulting to ~/.local/state when XDG_STATE_HOME is unset.
1002
1040
  function xdgStateFilePath(env = process.env) {
@@ -1047,11 +1085,14 @@ function normalizePersistenceOptions(options = {}, { env = process.env, cwd } =
1047
1085
  : ledgerPathFor(stateFilePath)
1048
1086
  const ledgerMaxBytes = toPositiveInteger(options.ledgerMaxBytes, DEFAULT_LEDGER_MAX_BYTES)
1049
1087
  const ledgerRetentionFiles = Number.isSafeInteger(options.ledgerRetentionFiles) && options.ledgerRetentionFiles >= 0
1050
- ? Math.min(options.ledgerRetentionFiles, 10)
1051
- : DEFAULT_LEDGER_RETENTION_FILES
1088
+ ? Math.min(options.ledgerRetentionFiles, 10)
1089
+ : DEFAULT_LEDGER_RETENTION_FILES
1090
+ const sessionDirectory = sessionDirectoryFor(stateFilePath)
1052
1091
  return {
1053
1092
  persistState,
1054
1093
  stateFilePath,
1094
+ sessionDirectory,
1095
+ migrationMarkerPath: join(sessionDirectory, ".migration-v1-complete"),
1055
1096
  fallbackPaths,
1056
1097
  ledgerFilePath,
1057
1098
  ledgerMaxBytes,
@@ -1283,7 +1324,7 @@ function deserializeGoal(goal) {
1283
1324
  // Parse one state-file body and apply it to runtime state. Returns "loaded" on
1284
1325
  // success or "invalid" when the version/shape is unsupported. Throws on
1285
1326
  // JSON.parse failure (handled by the caller).
1286
- async function applyParsedStateFile(raw, client) {
1327
+ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1287
1328
  const parsed = JSON.parse(raw)
1288
1329
  if (parsed?.version !== STATE_FILE_VERSION) {
1289
1330
  await logPluginError(
@@ -1303,6 +1344,7 @@ async function applyParsedStateFile(raw, client) {
1303
1344
  const loadedGoalCounts = new Map()
1304
1345
  for (const rawGoal of parsed.goals.slice(0, MAX_PERSISTED_ENTRIES)) {
1305
1346
  const normalizedGoal = normalizePersistedGoal(rawGoal)
1347
+ if (onlySessionID && normalizedGoal?.sessionID !== onlySessionID) continue
1306
1348
  const sessionCount = normalizedGoal
1307
1349
  ? loadedGoalCounts.get(normalizedGoal.sessionID) || 0
1308
1350
  : 0
@@ -1318,6 +1360,7 @@ async function applyParsedStateFile(raw, client) {
1318
1360
  let skippedResults = 0
1319
1361
  for (const rawResult of parsed.results.slice(-MAX_PERSISTED_ENTRIES)) {
1320
1362
  const normalizedResult = normalizePersistedResult(rawResult)
1363
+ if (onlySessionID && normalizedResult?.sessionID !== onlySessionID) continue
1321
1364
  if (normalizedResult) {
1322
1365
  loadedResults.push(normalizedResult)
1323
1366
  } else {
@@ -1332,7 +1375,8 @@ async function applyParsedStateFile(raw, client) {
1332
1375
  )
1333
1376
  }
1334
1377
 
1335
- clearRuntimeState()
1378
+ if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1379
+ else clearRuntimeState()
1336
1380
 
1337
1381
  const focusBySession = new Map()
1338
1382
  for (const { goal, focused } of loadedGoals) {
@@ -1345,6 +1389,7 @@ async function applyParsedStateFile(raw, client) {
1345
1389
  // Restore focus. Older single-goal state files have no `focused` flag, so
1346
1390
  // fall back to focusing a session's first (typically only) goal.
1347
1391
  for (const [sessionID, goalMap] of sessionGoals.entries()) {
1392
+ if (onlySessionID && sessionID !== onlySessionID) continue
1348
1393
  const focusTarget = focusBySession.get(sessionID) || goalMap.values().next().value
1349
1394
  if (focusTarget) focusGoal(sessionID, focusTarget)
1350
1395
  }
@@ -1356,6 +1401,7 @@ async function applyParsedStateFile(raw, client) {
1356
1401
  if (Array.isArray(parsed.archives)) {
1357
1402
  for (const entry of parsed.archives.slice(-MAX_PERSISTED_ENTRIES)) {
1358
1403
  if (!isPlainObject(entry) || typeof entry.sessionID !== "string" || !entry.sessionID) continue
1404
+ if (onlySessionID && entry.sessionID !== onlySessionID) continue
1359
1405
  const results = Array.isArray(entry.results)
1360
1406
  ? entry.results.map(normalizePersistedResult).filter(Boolean)
1361
1407
  : []
@@ -1367,6 +1413,7 @@ async function applyParsedStateFile(raw, client) {
1367
1413
 
1368
1414
  if (Array.isArray(parsed.orderedSessions)) {
1369
1415
  for (const sessionID of parsed.orderedSessions) {
1416
+ if (onlySessionID && sessionID !== onlySessionID) continue
1370
1417
  // Only honor the ordered flag for sessions that still have goals loaded.
1371
1418
  if (typeof sessionID === "string" && sessionGoals.has(sessionID)) {
1372
1419
  sessionOrdered.add(sessionID)
@@ -1381,7 +1428,7 @@ async function applyParsedStateFile(raw, client) {
1381
1428
  // terminal events. If a goal has a "completed" or "cleared" entry in the ledger
1382
1429
  // but still appears active in the state file (because the state write failed
1383
1430
  // after the terminal ledger write), remove it so it is not re-driven.
1384
- async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1431
+ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
1385
1432
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1386
1433
  maxBytes: persistenceOptions.ledgerMaxBytes,
1387
1434
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
@@ -1395,6 +1442,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1395
1442
  typeof entry.sessionID === "string" && entry.sessionID &&
1396
1443
  typeof entry.goalId === "string" && entry.goalId
1397
1444
  ) {
1445
+ if (onlySessionID && entry.sessionID !== onlySessionID) continue
1398
1446
  terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
1399
1447
  }
1400
1448
  }
@@ -1402,6 +1450,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1402
1450
 
1403
1451
  let removed = 0
1404
1452
  for (const [sessionID, goals] of sessionGoals.entries()) {
1453
+ if (onlySessionID && sessionID !== onlySessionID) continue
1405
1454
  for (const goal of [...goals.values()]) {
1406
1455
  if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
1407
1456
  removeSessionGoal(sessionID, goal.goalId)
@@ -1420,114 +1469,250 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
1420
1469
  }
1421
1470
  }
1422
1471
 
1423
- async function loadPersistedState(persistenceOptions, client) {
1424
- 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
+ }
1425
1481
 
1426
- const candidates = [
1427
- { path: persistenceOptions.stateFilePath, primary: true },
1428
- ...(persistenceOptions.fallbackPaths || []).map((path) => ({ path, primary: false })),
1429
- ]
1430
- const recoverInvalidPrimary = async () => {
1431
- const status = await reconstructFromLedger(persistenceOptions, client)
1432
- if (status !== "reconstructed") return "invalid"
1433
- 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
1434
1486
  try {
1435
- 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) {
1436
1502
  await logPluginError(
1437
1503
  client,
1438
- `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.`,
1439
1505
  )
1440
- } catch (error) {
1441
- await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
1442
- return "invalid"
1506
+ return { status: "invalid" }
1443
1507
  }
1444
- 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" }
1445
1513
  }
1446
1514
 
1447
- for (const { path, primary } of candidates) {
1448
- let migrationLease = null
1449
- if (!primary) {
1450
- try {
1451
- migrationLease = await acquirePersistenceLease(path)
1452
- currentRuntime().migrationLease = migrationLease
1453
- } catch (error) {
1454
- await logPluginError(client, `Skipped legacy state migration because another process owns ${path}.`, error)
1455
- continue
1456
- }
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" }
1457
1520
  }
1458
- 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
1459
1618
  try {
1460
- const info = await fs.lstat(path)
1461
- if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_STATE_FILE_BYTES) {
1462
- await logPluginError(
1463
- client,
1464
- `Skipped persisted goal state: file is not regular or exceeds ${MAX_STATE_FILE_BYTES} bytes.`,
1465
- )
1466
- if (primary) return recoverInvalidPrimary()
1467
- await migrationLease?.release()
1468
- 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)
1469
1653
  }
1470
- raw = await fs.readFile(path, "utf8")
1471
- } catch (error) {
1472
- if (error?.code === "ENOENT") {
1473
- await migrationLease?.release()
1474
- 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
+ }
1475
1664
  }
1476
- // A present-but-unreadable primary file should not be silently
1477
- // overwritten, so report it as invalid rather than missing.
1478
- await logPluginError(client, "Failed to load persisted goal state", error)
1479
- if (primary) return "invalid"
1480
- await migrationLease?.release()
1481
- continue
1665
+ return
1666
+ } finally {
1667
+ await migrationLease.release()
1482
1668
  }
1669
+ }
1670
+
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
+ }
1483
1675
 
1484
- let status
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()}`
1485
1686
  try {
1486
- 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
+ )
1487
1692
  } catch (error) {
1488
- await logPluginError(client, "Failed to load persisted goal state", error)
1489
- if (primary) return recoverInvalidPrimary()
1490
- await migrationLease?.release()
1491
- continue
1492
- }
1493
-
1494
- if (status === "loaded") {
1495
- // Cross-check: the ledger is written before the state file for terminal
1496
- // events (completed, cleared). If the terminal persist succeeded in the
1497
- // ledger but the state file write failed (e.g. process killed between the
1498
- // two writes), the reloaded state may still have the goal as active. Remove
1499
- // any loaded active goals whose goalId has a terminal ledger entry.
1500
- await reconcileLoadedStateWithLedger(persistenceOptions, client)
1501
- if (primary) return "loaded"
1502
- persistenceOptions.migrationClaim = { path, lease: migrationLease }
1503
- currentRuntime().migrationLease = migrationLease
1504
- return "migrated"
1693
+ await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
1505
1694
  }
1506
- // status === "invalid": preserve a present-but-corrupt primary; for a
1507
- // fallback, keep trying the next candidate.
1508
- if (primary) return recoverInvalidPrimary()
1509
- await migrationLease?.release()
1510
1695
  }
1511
-
1512
- // No state file found at any candidate path → try reconstructing from the
1513
- // append-only ledger before giving up.
1514
- return reconstructFromLedger(persistenceOptions, client)
1696
+ return recovered
1515
1697
  }
1516
1698
 
1517
1699
  // Last-resort recovery: when the main state file is absent, rebuild still-active
1518
1700
  // goals from the append-only ledger so a lost/rotated state file does not drop
1519
1701
  // in-flight goals. Recovered goals are paused (via deserializeGoal).
1520
- async function reconstructFromLedger(persistenceOptions, client) {
1702
+ async function reconstructFromLedger(persistenceOptions, client, onlySessionID = null) {
1521
1703
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1522
1704
  maxBytes: persistenceOptions.ledgerMaxBytes,
1523
1705
  retentionFiles: persistenceOptions.ledgerRetentionFiles,
1524
1706
  })
1525
1707
  if (!entries.length) return "missing"
1526
1708
 
1527
- const reconstructed = reconstructGoalsFromLedger(entries)
1709
+ const reconstructed = reconstructGoalsFromLedger(entries).filter(
1710
+ (goal) => !onlySessionID || goal.sessionID === onlySessionID,
1711
+ )
1528
1712
  if (!reconstructed.length) return "missing"
1529
1713
 
1530
- clearRuntimeState()
1714
+ if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1715
+ else clearRuntimeState()
1531
1716
  const focusCandidates = new Map()
1532
1717
  for (const stub of reconstructed) {
1533
1718
  const normalized = normalizePersistedGoal(stub)
@@ -1539,6 +1724,7 @@ async function reconstructFromLedger(persistenceOptions, client) {
1539
1724
  }
1540
1725
  }
1541
1726
  for (const [sessionID, goals] of sessionGoals.entries()) {
1727
+ if (onlySessionID && sessionID !== onlySessionID) continue
1542
1728
  const preferred = focusCandidates.get(sessionID)
1543
1729
  const focused = (preferred && goals.get(preferred)) || goals.values().next().value
1544
1730
  if (focused) focusGoal(sessionID, focused)
@@ -1550,55 +1736,46 @@ async function reconstructFromLedger(persistenceOptions, client) {
1550
1736
  return goalStates.size > 0 ? "reconstructed" : "missing"
1551
1737
  }
1552
1738
 
1553
- async function persistState(persistenceOptions, client) {
1554
- if (!persistenceOptions.persistState) return true
1555
-
1556
- const tmpPath = `${persistenceOptions.stateFilePath}.${process.pid}.${randomUUID()}.tmp`
1557
- try {
1558
- await fs.mkdir(dirname(persistenceOptions.stateFilePath), { recursive: true, mode: 0o700 })
1559
- await fs.writeFile(
1560
- tmpPath,
1561
- JSON.stringify(
1562
- {
1563
- version: STATE_FILE_VERSION,
1564
- // All live goals across sessions, each flagged whether it is the
1565
- // session's focused goal so focus survives a restart.
1566
- goals: [...sessionGoals.values()]
1567
- .flatMap((map) => [...map.values()])
1568
- .slice(-MAX_PERSISTED_ENTRIES)
1569
- .map((goal) => ({
1570
- ...serializeGoal(goal),
1571
- focused: goalStates.get(goal.sessionID)?.goalId === goal.goalId,
1572
- })),
1573
- 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) => ({
1574
1761
  ...result,
1575
1762
  sessionID,
1576
1763
  history: [...(result.history || [])],
1577
1764
  checkpoints: [...(result.checkpoints || [])],
1578
1765
  lastCheckpoint: result.lastCheckpoint || null,
1579
1766
  })),
1580
- archives: [...sessionArchive.entries()].slice(-MAX_PERSISTED_ENTRIES).map(([sessionID, results]) => ({
1581
- sessionID,
1582
- results: results.map((result) => ({
1583
- ...result,
1584
- sessionID,
1585
- history: [...(result.history || [])],
1586
- checkpoints: [...(result.checkpoints || [])],
1587
- lastCheckpoint: result.lastCheckpoint || null,
1588
- })),
1589
- })),
1590
- orderedSessions: [...sessionOrdered].slice(-MAX_PERSISTED_ENTRIES),
1591
- },
1592
- null,
1593
- 2,
1594
- ),
1595
- { encoding: "utf8", mode: 0o600 },
1596
- )
1597
- await fs.rename(tmpPath, persistenceOptions.stateFilePath)
1598
- 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))
1599
1777
  return true
1600
1778
  } catch (error) {
1601
- await fs.rm(tmpPath, { force: true }).catch(() => {})
1602
1779
  await logPluginError(client, "Failed to persist goal state", error)
1603
1780
  return false
1604
1781
  }
@@ -2342,10 +2519,6 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2342
2519
  return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
2343
2520
  if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
2344
2521
  return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
2345
- if (!goalStates.has(sessionID) && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
2346
- return `The plugin already tracks ${MAX_PERSISTED_ENTRIES} live goals; clear or complete one before creating another.`
2347
- }
2348
-
2349
2522
  const options = normalizeOptions({
2350
2523
  ...defaultGoalOptions,
2351
2524
  ...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
@@ -2371,7 +2544,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2371
2544
  lastGoalResults.delete(sessionID)
2372
2545
  registerSessionGoal(goal)
2373
2546
  focusGoal(sessionID, goal)
2374
- await persist()
2547
+ await persist(sessionID)
2375
2548
  // Escape in the tool result only: goal.condition is stored raw so callers
2376
2549
  // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
2377
2550
  // themselves. Escaping here prevents XML metacharacters in user-supplied
@@ -2453,7 +2626,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2453
2626
  goal.stopReason = "audit rejected"
2454
2627
  goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
2455
2628
  pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
2456
- await persist()
2629
+ await persist(sessionID)
2457
2630
  return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
2458
2631
  }
2459
2632
  }
@@ -2468,7 +2641,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2468
2641
  cleanupGoal(sessionID)
2469
2642
  // Advance an ordered sequence just like the marker path does.
2470
2643
  if (ordered) promoteNextOrderedGoal(sessionID)
2471
- const durable = await persistFinal("completion", ledgerDurable)
2644
+ const durable = await persistFinal(sessionID, "completion", ledgerDurable)
2472
2645
  if (durable === false) {
2473
2646
  restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
2474
2647
  return "Completion verified, but terminal state could not be persisted. Goal remains paused."
@@ -2512,7 +2685,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2512
2685
  if (!messages.length) {
2513
2686
  return "Nothing to update. Provide `objective` and/or `status`."
2514
2687
  }
2515
- await persist()
2688
+ await persist(sessionID)
2516
2689
  return messages.join(" ")
2517
2690
  }
2518
2691
 
@@ -2528,7 +2701,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2528
2701
  sessionGoals.delete(sessionID)
2529
2702
  cleanupGoal(sessionID)
2530
2703
  lastGoalResults.delete(sessionID)
2531
- await persistFinal("clear")
2704
+ await persistFinal(sessionID, "clear")
2532
2705
  return "Goal cleared."
2533
2706
  }
2534
2707
 
@@ -2553,11 +2726,12 @@ async function loadOpencodePluginModule() {
2553
2726
  return opencodePluginModulePromise
2554
2727
  }
2555
2728
 
2556
- function buildAgentTools(toolHelper, handlers) {
2729
+ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
2557
2730
  const schema = toolHelper.schema
2558
2731
  const run = (handler) => async (args, ctx) => {
2559
2732
  const sessionID = agentToolSessionID(ctx)
2560
2733
  if (!sessionID) return "No session id available for the goal tool."
2734
+ await ensureSessionLoaded(sessionID)
2561
2735
  return handler(sessionID, args || {})
2562
2736
  }
2563
2737
  // Canonical tools use a small, versioned machine-readable envelope. Keep the
@@ -2571,6 +2745,7 @@ function buildAgentTools(toolHelper, handlers) {
2571
2745
  goalToolFailure("missing_session", "No session id available for the goal tool."),
2572
2746
  )
2573
2747
  }
2748
+ await ensureSessionLoaded(sessionID)
2574
2749
  return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
2575
2750
  }
2576
2751
 
@@ -2902,30 +3077,67 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2902
3077
  env: pluginOptions.env,
2903
3078
  cwd: pluginOptions.cwd || directory,
2904
3079
  })
2905
- if (persistenceOptions.persistState) {
2906
- await assertSafeProjectPersistencePath(persistenceOptions)
2907
- currentRuntime().persistenceLease = await acquirePersistenceLease(persistenceOptions.stateFilePath)
2908
- }
2909
3080
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
2910
- // Serialize all persist() calls through a promise chain so concurrent callers
2911
- // never race on the temp-file rename. persistState returns a boolean and never
2912
- // rejects, so the chain cannot stall on a thrown error.
2913
- let persistChain = Promise.resolve(true)
2914
- const persist = () => {
2915
- if (runtime.disposed) return Promise.resolve(false)
2916
- 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
2917
3089
  .catch(() => false)
2918
- .then(() => persistState(persistenceOptions, client))
2919
- 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
+ }
2920
3133
  }
2921
- runtime.drainPersistence = () => persistChain.catch(() => false)
2922
3134
 
2923
3135
  // Fail closed when persisting a terminal state (complete/blocked)
2924
3136
  // fails, surface it loudly. The terminal event is already in the append-only
2925
3137
  // ledger, so it stays recoverable across a restart even though the main state
2926
3138
  // file write did not land.
2927
- const persistTerminalState = async (label, ledgerDurable = false) => {
2928
- const stateDurable = await persist()
3139
+ const persistTerminalState = async (sessionID, label, ledgerDurable = false) => {
3140
+ const stateDurable = await persist(sessionID)
2929
3141
  if (!stateDurable && persistenceOptions.persistState) {
2930
3142
  await logPluginError(
2931
3143
  client,
@@ -2939,10 +3151,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2939
3151
 
2940
3152
  // Route lifecycle events to the JSONL ledger only when persistence is on.
2941
3153
  if (persistenceOptions.persistState) {
2942
- setLedgerSink((entry) => appendLedgerLine(persistenceOptions.ledgerFilePath, entry, {
2943
- maxBytes: persistenceOptions.ledgerMaxBytes,
2944
- retentionFiles: persistenceOptions.ledgerRetentionFiles,
2945
- }))
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
+ })
2946
3162
  } else {
2947
3163
  setLedgerSink(null)
2948
3164
  }
@@ -2985,34 +3201,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2985
3201
  : null
2986
3202
 
2987
3203
  clearRuntimeState()
2988
- const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
2989
- pruneGoalResults(defaultGoalOptions)
2990
- // "migrated" = loaded from a legacy/XDG fallback path; "reconstructed" =
2991
- // rebuilt from the ledger. Both persist forward to the resolved path.
2992
- if (
2993
- persistedStateStatus === "loaded" ||
2994
- persistedStateStatus === "missing" ||
2995
- persistedStateStatus === "migrated" ||
2996
- persistedStateStatus === "reconstructed"
2997
- ) {
2998
- const initialPersisted = await persist()
2999
- if (persistedStateStatus === "migrated" && persistenceOptions.migrationClaim) {
3000
- const { path, lease } = persistenceOptions.migrationClaim
3001
- if (initialPersisted) {
3002
- const backupPath = `${path}.migrated.${Date.now()}.${randomUUID()}`
3003
- try {
3004
- await fs.rename(path, backupPath)
3005
- } catch (error) {
3006
- await logPluginError(client, `Could not retire migrated legacy goal state at ${path}.`, error)
3007
- }
3008
- }
3009
- await lease.release()
3010
- runtime.migrationLease = null
3011
- persistenceOptions.migrationClaim = null
3012
- }
3013
- }
3014
3204
 
3015
- const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName })
3205
+ const agentToolHandlers = buildAgentToolHandlers({
3206
+ defaultGoalOptions,
3207
+ persist,
3208
+ persistTerminalState,
3209
+ completionAuditor,
3210
+ commandName,
3211
+ })
3016
3212
 
3017
3213
  const abortAcceptedContinuation = async (sessionID) => {
3018
3214
  const runtimeState = currentRuntime()
@@ -3043,7 +3239,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3043
3239
  goal.continuationClaim = null
3044
3240
  pushHistory(goal, "paused", history)
3045
3241
  activeContinues.delete(sessionID)
3046
- await persist()
3242
+ await persist(sessionID)
3047
3243
  if (abortAccepted) await abortAcceptedContinuation(sessionID)
3048
3244
  return true
3049
3245
  }
@@ -3113,7 +3309,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3113
3309
  }
3114
3310
 
3115
3311
  goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
3116
- const claimPersisted = await persist()
3312
+ const claimPersisted = await persist(sessionID)
3117
3313
  if (!claimPersisted && persistenceOptions.persistState) {
3118
3314
  goal.continuationClaim = null
3119
3315
  goal.stopped = true
@@ -3135,6 +3331,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3135
3331
  },
3136
3332
  "chat.params": async (input) => {
3137
3333
  if (!input?.sessionID) return
3334
+ await ensureSessionLoaded(input.sessionID)
3138
3335
  const context = normalizeExecutionContext({
3139
3336
  agent: input.agent,
3140
3337
  model: input.model,
@@ -3145,6 +3342,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3145
3342
  "chat.message": async (input, output) => {
3146
3343
  const sessionID = input?.sessionID
3147
3344
  if (!sessionID) return
3345
+ await ensureSessionLoaded(sessionID)
3148
3346
  const context = normalizeExecutionContext(input)
3149
3347
  if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3150
3348
 
@@ -3165,7 +3363,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3165
3363
  },
3166
3364
  "tool.execute.before": async (input) => {
3167
3365
  const sessionID = input?.sessionID
3168
- if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
3366
+ if (!sessionID) return
3367
+ await ensureSessionLoaded(sessionID)
3368
+ if (!currentRuntime().readOnlyCommandGuards.has(sessionID)) return
3169
3369
  if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
3170
3370
  throw new Error(
3171
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.`,
@@ -3184,6 +3384,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3184
3384
  }
3185
3385
  const args = input.arguments.trim()
3186
3386
  const sessionID = input.sessionID
3387
+ await ensureSessionLoaded(sessionID)
3187
3388
  currentRuntime().readOnlyCommandGuards.delete(sessionID)
3188
3389
  pruneGoalResults(defaultGoalOptions)
3189
3390
 
@@ -3246,7 +3447,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3246
3447
  sessionGoals.delete(sessionID)
3247
3448
  cleanupGoal(sessionID)
3248
3449
  lastGoalResults.delete(sessionID)
3249
- await persist()
3450
+ await persist(sessionID)
3250
3451
  output.parts = [makeTextPart("Goal cleared.")]
3251
3452
  return
3252
3453
  }
@@ -3265,7 +3466,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3265
3466
  goal.continuationClaim = null
3266
3467
  activeContinues.delete(sessionID)
3267
3468
  pushHistory(goal, "paused", "User paused the active goal.")
3268
- await persist()
3469
+ await persist(sessionID)
3269
3470
  await abortAcceptedContinuation(sessionID)
3270
3471
  output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
3271
3472
  return
@@ -3291,7 +3492,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3291
3492
  goal.blockedReason = ""
3292
3493
  goal.lastStatus = "Goal resumed with a fresh local budget."
3293
3494
  pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
3294
- await persist()
3495
+ await persist(sessionID)
3295
3496
  output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
3296
3497
  return
3297
3498
  }
@@ -3331,7 +3532,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3331
3532
  goal.continuationClaim = null
3332
3533
  goal.lastStatus = "Goal objective updated."
3333
3534
  pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
3334
- await persist()
3535
+ await persist(sessionID)
3335
3536
  output.parts = [
3336
3537
  makeTextPart(
3337
3538
  [
@@ -3371,11 +3572,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3371
3572
  output.parts = [makeTextPart(`An ordered sequence may contain at most ${MAX_LIVE_GOALS_PER_SESSION} goals.`)]
3372
3573
  return
3373
3574
  }
3374
- const existingCount = listSessionGoals(sessionID).length
3375
- if (totalLiveGoals() - existingCount + objectives.length > MAX_PERSISTED_ENTRIES) {
3376
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3377
- return
3378
- }
3379
3575
  if (objectives.some((objective) => objective.length > MAX_GOAL_OBJECTIVE_LENGTH)) {
3380
3576
  output.parts = [makeTextPart(`Each goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
3381
3577
  return
@@ -3412,7 +3608,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3412
3608
  })
3413
3609
  focusGoal(sessionID, firstGoal)
3414
3610
  sessionOrdered.add(sessionID)
3415
- await persist()
3611
+ await persist(sessionID)
3416
3612
  output.parts = [
3417
3613
  makeTextPart(
3418
3614
  [
@@ -3471,7 +3667,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3471
3667
  resumeGoalClock(target)
3472
3668
  pushHistory(target, "focused", "Brought into focus as the session's active goal.")
3473
3669
  focusGoal(sessionID, target)
3474
- await persist()
3670
+ await persist(sessionID)
3475
3671
  output.parts = [
3476
3672
  makeTextPart(
3477
3673
  [
@@ -3511,10 +3707,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3511
3707
  output.parts = [makeTextPart(`A session may contain at most ${MAX_LIVE_GOALS_PER_SESSION} live goals.`)]
3512
3708
  return
3513
3709
  }
3514
- if (totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
3515
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3516
- return
3517
- }
3518
3710
  // Keep the current goal (background it) and focus a new one.
3519
3711
  const current = goalStates.get(sessionID)
3520
3712
  if (current) {
@@ -3531,7 +3723,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3531
3723
  )
3532
3724
  registerSessionGoal(added)
3533
3725
  focusGoal(sessionID, added)
3534
- await persist()
3726
+ await persist(sessionID)
3535
3727
  const total = listSessionGoals(sessionID).length
3536
3728
  output.parts = [
3537
3729
  makeTextPart(
@@ -3551,10 +3743,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3551
3743
  }
3552
3744
 
3553
3745
  const replacedGoal = goalStates.get(sessionID)
3554
- if (!replacedGoal && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
3555
- output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
3556
- return
3557
- }
3558
3746
  const goal = buildGoalState(sessionID, parsed.condition, parsed.options, parsed.meta)
3559
3747
 
3560
3748
  pushHistory(
@@ -3573,7 +3761,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3573
3761
  lastGoalResults.delete(sessionID)
3574
3762
  registerSessionGoal(goal)
3575
3763
  focusGoal(sessionID, goal)
3576
- await persist()
3764
+ await persist(sessionID)
3577
3765
  output.parts = [
3578
3766
  makeTextPart(
3579
3767
  [
@@ -3605,6 +3793,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3605
3793
  },
3606
3794
 
3607
3795
  event: async ({ event }) => {
3796
+ const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
3797
+ if (eventSessionID) await ensureSessionLoaded(eventSessionID)
3798
+
3608
3799
  if (event?.type === "session.status") {
3609
3800
  const sessionID = getSessionID(event)
3610
3801
  const status = event?.properties?.status?.type || event?.data?.status?.type
@@ -3641,7 +3832,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3641
3832
  if (!goal) return
3642
3833
  goal.messageIDs = new Set()
3643
3834
  goal.totalTokens = 0
3644
- await persist()
3835
+ await persist(sessionID)
3645
3836
  return
3646
3837
  }
3647
3838
 
@@ -3700,7 +3891,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3700
3891
  changed = true
3701
3892
  }
3702
3893
 
3703
- if (changed) await persist()
3894
+ if (changed) await persist(messageSessionID(message))
3704
3895
  return
3705
3896
  }
3706
3897
 
@@ -3835,7 +4026,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3835
4026
  auditedGoal.stopReason = "audit rejected"
3836
4027
  auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
3837
4028
  pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
3838
- await persist()
4029
+ await persist(sessionID)
3839
4030
  await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
3840
4031
  return
3841
4032
  }
@@ -3863,7 +4054,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3863
4054
  if (ordered) {
3864
4055
  promoteNextOrderedGoal(sessionID)
3865
4056
  }
3866
- const durable = await persistTerminalState("completion", ledgerDurable)
4057
+ const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
3867
4058
  if (durable === false) {
3868
4059
  restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
3869
4060
  await announceAudit(
@@ -3897,7 +4088,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3897
4088
  blockedGoal.stopped = true
3898
4089
  blockedGoal.stopReason = "blocked"
3899
4090
  const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
3900
- const durable = await persistTerminalState("blocked", ledgerDurable)
4091
+ const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
3901
4092
  if (durable === false) {
3902
4093
  blockedGoal.stopReason = "terminal persistence failed"
3903
4094
  blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
@@ -3937,7 +4128,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3937
4128
  claimedGoal.stopReason = limitReason
3938
4129
  claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
3939
4130
  pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
3940
- await persist()
4131
+ await persist(sessionID)
3941
4132
  currentRuntime().promptInFlightSessions.add(sessionID)
3942
4133
  let response
3943
4134
  try {
@@ -3958,7 +4149,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3958
4149
  activeGoalAfterMessages.lastStatus = limitReason
3959
4150
  pushHistory(activeGoalAfterMessages, "limit", limitReason)
3960
4151
  }
3961
- await persist()
4152
+ await persist(sessionID)
3962
4153
  return
3963
4154
  }
3964
4155
 
@@ -4013,7 +4204,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4013
4204
  "paused",
4014
4205
  `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
4015
4206
  )
4016
- await persist()
4207
+ await persist(sessionID)
4017
4208
  return
4018
4209
  }
4019
4210
 
@@ -4058,7 +4249,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4058
4249
  "paused",
4059
4250
  `Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
4060
4251
  )
4061
- await persist()
4252
+ await persist(sessionID)
4062
4253
  return
4063
4254
  }
4064
4255
 
@@ -4107,7 +4298,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4107
4298
  // promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
4108
4299
  // the hard-limit path which also persists before its promptAsync call.
4109
4300
  pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
4110
- await persist()
4301
+ await persist(sessionID)
4111
4302
  }
4112
4303
 
4113
4304
  activeGoalBeforePrompt.turnCount += 1
@@ -4147,7 +4338,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4147
4338
  "paused",
4148
4339
  `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
4149
4340
  )
4150
- await persist()
4341
+ await persist(sessionID)
4151
4342
  return
4152
4343
  }
4153
4344
  }
@@ -4208,7 +4399,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4208
4399
  )
4209
4400
  }
4210
4401
  }
4211
- await persist()
4402
+ await persist(sessionID)
4212
4403
  } catch (error) {
4213
4404
  const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
4214
4405
  if (activeGoalAfterError) {
@@ -4228,7 +4419,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4228
4419
  activeGoalAfterError.stopReason = "auto-continue failures"
4229
4420
  activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
4230
4421
  }
4231
- await persist()
4422
+ await persist(sessionID)
4232
4423
  }
4233
4424
  await logPluginError(client, "Auto-continue failed", error)
4234
4425
  } finally {
@@ -4245,6 +4436,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4245
4436
 
4246
4437
  "experimental.chat.system.transform": async (input, output) => {
4247
4438
  if (!input.sessionID) return
4439
+ await ensureSessionLoaded(input.sessionID)
4248
4440
 
4249
4441
  const goal = goalStates.get(input.sessionID)
4250
4442
  if (!goal) return
@@ -4294,6 +4486,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4294
4486
 
4295
4487
  "experimental.session.compacting": async (input, output) => {
4296
4488
  if (!input?.sessionID || !output) return
4489
+ await ensureSessionLoaded(input.sessionID)
4297
4490
  const goal = goalStates.get(input.sessionID)
4298
4491
  if (!goal) return
4299
4492
  const context = buildCompactionContext(goal)
@@ -4313,6 +4506,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4313
4506
  // auto-continue to avoid two continuations racing after a compaction.
4314
4507
  // Paused/stopped goals leave the native behavior untouched.
4315
4508
  if (!input?.sessionID || !output) return
4509
+ await ensureSessionLoaded(input.sessionID)
4316
4510
  const goal = goalStates.get(input.sessionID)
4317
4511
  if (!goal || goal.stopped) return
4318
4512
  output.enabled = false
@@ -4334,7 +4528,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4334
4528
  const toolModule = await loadOpencodePluginModule()
4335
4529
  if (toolModule?.tool?.schema) {
4336
4530
  try {
4337
- hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers)
4531
+ hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers, ensureSessionLoaded)
4338
4532
  } catch (error) {
4339
4533
  await logPluginError(client, "Failed to register goal agent tools", error)
4340
4534
  }
@@ -4376,13 +4570,17 @@ function bindHooksToRuntime(hooks, runtime) {
4376
4570
  if (runtime.disposed) return
4377
4571
  runtime.disposed = true
4378
4572
  for (const controller of runtime.continuationControllers.values()) controller.abort()
4379
- 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
+ }
4380
4577
  clearRuntimeState()
4381
4578
  setLedgerSink(null)
4382
- await runtime.persistenceLease?.release()
4383
- runtime.persistenceLease = null
4384
- await runtime.migrationLease?.release()
4385
- 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()
4386
4584
  })
4387
4585
  return bound
4388
4586
  }
@@ -4396,11 +4594,13 @@ export const GoalPlugin = async (context = {}, pluginOptions = {}) => {
4396
4594
  return bindHooksToRuntime(hooks, runtime)
4397
4595
  } catch (error) {
4398
4596
  runtime.disposed = true
4399
- await runtime.drainPersistence?.()
4400
- await runtime.persistenceLease?.release().catch(() => false)
4401
- runtime.persistenceLease = null
4402
- await runtime.migrationLease?.release().catch(() => false)
4403
- 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()
4404
4604
  throw error
4405
4605
  }
4406
4606
  })
@@ -4457,6 +4657,7 @@ export const testInternals = {
4457
4657
  normalizeMessageUsage,
4458
4658
  normalizeUsage,
4459
4659
  normalizePersistenceOptions,
4660
+ sessionPathsFor,
4460
4661
  userInterventionDetected,
4461
4662
  outputTokensForMessage,
4462
4663
  parseGoalArguments,
@@ -23,9 +23,9 @@ async function readOwner(lockPath) {
23
23
  }
24
24
 
25
25
  /**
26
- * Hold an exclusive workspace lease for the plugin instance lifetime. This
27
- * deliberately rejects a second writer instead of allowing stale full-state
28
- * snapshots to overwrite each other.
26
+ * Hold an exclusive session lease for the plugin instance lifetime. This
27
+ * deliberately rejects a second writer instead of allowing stale snapshots to
28
+ * overwrite the same session's state.
29
29
  */
30
30
  export async function acquirePersistenceLease(
31
31
  stateFilePath,