opencode-goal-plugin 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,10 @@ import { createOpenCodeSessionApi } from "./opencode-session-api.js"
19
19
  import { applyNativeGoalConfig } from "./native-agent-config.js"
20
20
  import { serializeCompletionClaim } from "./completion-claim.js"
21
21
  import { goalToolFailure, goalToolSuccess, serializeGoalToolResult } from "./goal-tool-result.js"
22
- import { acquirePersistenceLease } from "./persistence-lease.js"
22
+ import {
23
+ acquirePersistenceLease,
24
+ isPersistenceLeaseContendedError,
25
+ } from "./persistence-lease.js"
23
26
 
24
27
  const STATE_FILE_VERSION = 1
25
28
  // Default state now follows the project: <cwd>/.opencode/goals/state.json.
@@ -56,6 +59,11 @@ const DEFAULT_LEDGER_RETENTION_FILES = 3
56
59
  const MAX_LEDGER_LINE_BYTES = 16 * 1024
57
60
  const MIGRATION_LEASE_RETRIES = 200
58
61
  const MIGRATION_LEASE_DELAY_MS = 25
62
+ const PASSIVE_SESSION_RETRY_MS = 250
63
+ const SESSION_OWNED_ELSEWHERE = "session_owned_elsewhere"
64
+ const ACTIVE_PERSISTENCE_DISABLED = Object.freeze({ kind: "active", persistence: "disabled" })
65
+ const ACTIVE_PERSISTENCE_OWNED = Object.freeze({ kind: "active", persistence: "owned" })
66
+ const PLUGIN_DISPOSED = Object.freeze({ kind: "disposed" })
59
67
 
60
68
  const DEFAULT_OPTIONS = {
61
69
  maxTurns: 10,
@@ -104,6 +112,7 @@ function createRuntimeState() {
104
112
  ledgerSink: null,
105
113
  sessionPersistence: new Map(),
106
114
  sessionLoadPromises: new Map(),
115
+ passiveSessions: new Map(),
107
116
  disposed: false,
108
117
  }
109
118
  }
@@ -115,6 +124,19 @@ function currentRuntime() {
115
124
  return runtimeStorage.getStore() || lastRuntime
116
125
  }
117
126
 
127
+ function runtimeSessionDiagnostics(sessionID) {
128
+ const runtime = currentRuntime()
129
+ return Object.freeze({
130
+ disposed: runtime.disposed,
131
+ loadInFlight: runtime.sessionLoadPromises.has(sessionID),
132
+ persistenceOwned: runtime.sessionPersistence.has(sessionID),
133
+ passive: runtime.passiveSessions.has(sessionID),
134
+ suppressedAssistantCount: [...runtime.suppressedCommandAssistants.values()]
135
+ .filter((ownerSessionID) => ownerSessionID === sessionID)
136
+ .length,
137
+ })
138
+ }
139
+
118
140
  // Route the existing domain helpers to the plugin instance associated with the
119
141
  // current async hook/tool execution. OpenCode caches imported plugin modules but
120
142
  // initializes their factories per workspace, so module-global Maps would let a
@@ -343,6 +365,24 @@ function normalizeExecutionContext(value) {
343
365
  }
344
366
  }
345
367
 
368
+ function rememberSessionExecutionContext(sessionID, value, { replace = false } = {}) {
369
+ if (!sessionID) return null
370
+ const observed = normalizeExecutionContext(value)
371
+ if (!observed) return null
372
+ const runtime = currentRuntime()
373
+ if (replace) {
374
+ runtime.sessionExecutionContexts.set(sessionID, observed)
375
+ return observed
376
+ }
377
+ const previous = normalizeExecutionContext(runtime.sessionExecutionContexts.get(sessionID)) || {}
378
+ const merged = {
379
+ ...previous,
380
+ ...observed,
381
+ }
382
+ runtime.sessionExecutionContexts.set(sessionID, merged)
383
+ return merged
384
+ }
385
+
346
386
  function continuationContextInput(goal) {
347
387
  const context = normalizeExecutionContext(goal?.executionContext)
348
388
  return context ? { ...context } : {}
@@ -841,9 +881,13 @@ function clearRuntimeState() {
841
881
  runtime.activeCommandTurns.clear()
842
882
  runtime.ownedPluginMessages.clear()
843
883
  runtime.suppressedCommandAssistants.clear()
884
+ runtime.passiveSessions.clear()
844
885
  }
845
886
 
846
- function clearSessionRuntimeState(sessionID) {
887
+ function clearSessionRuntimeState(
888
+ sessionID,
889
+ { preserveCommandSecurity = false, preserveExecutionContext = false } = {},
890
+ ) {
847
891
  const runtime = currentRuntime()
848
892
  for (const goal of sessionGoals.get(sessionID)?.values() || []) {
849
893
  for (const messageID of goal.messageIDs || []) {
@@ -862,14 +906,17 @@ function clearSessionRuntimeState(sessionID) {
862
906
  runtime.continuationControllers.delete(sessionID)
863
907
  runtime.promptInFlightSessions.delete(sessionID)
864
908
  runtime.sessionStatuses.delete(sessionID)
865
- runtime.sessionExecutionContexts.delete(sessionID)
866
- runtime.pendingCommandTurns.delete(sessionID)
867
- runtime.activeCommandTurns.delete(sessionID)
868
- for (const [messageID, owner] of runtime.ownedPluginMessages) {
869
- if (owner?.sessionID === sessionID) runtime.ownedPluginMessages.delete(messageID)
870
- }
871
- for (const [messageID, ownerSessionID] of runtime.suppressedCommandAssistants) {
872
- if (ownerSessionID === sessionID) runtime.suppressedCommandAssistants.delete(messageID)
909
+ if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
910
+ runtime.passiveSessions.delete(sessionID)
911
+ if (!preserveCommandSecurity) {
912
+ runtime.pendingCommandTurns.delete(sessionID)
913
+ runtime.activeCommandTurns.delete(sessionID)
914
+ for (const [messageID, owner] of runtime.ownedPluginMessages) {
915
+ if (owner?.sessionID === sessionID) runtime.ownedPluginMessages.delete(messageID)
916
+ }
917
+ for (const [messageID, ownerSessionID] of runtime.suppressedCommandAssistants) {
918
+ if (ownerSessionID === sessionID) runtime.suppressedCommandAssistants.delete(messageID)
919
+ }
873
920
  }
874
921
  }
875
922
 
@@ -1441,7 +1488,12 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
1441
1488
  )
1442
1489
  }
1443
1490
 
1444
- if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1491
+ if (onlySessionID) {
1492
+ clearSessionRuntimeState(onlySessionID, {
1493
+ preserveCommandSecurity: true,
1494
+ preserveExecutionContext: true,
1495
+ })
1496
+ }
1445
1497
  else clearRuntimeState()
1446
1498
 
1447
1499
  const focusBySession = new Map()
@@ -1552,7 +1604,7 @@ async function acquireMigrationLease(stateFilePath, migrationMarkerPath) {
1552
1604
  try {
1553
1605
  return await acquirePersistenceLease(stateFilePath)
1554
1606
  } catch (error) {
1555
- if (!String(error?.message || error).includes("goal persistence is already owned")) throw error
1607
+ if (!isPersistenceLeaseContendedError(error)) throw error
1556
1608
  lastError = error
1557
1609
  await new Promise((resolve) => setTimeout(resolve, MIGRATION_LEASE_DELAY_MS))
1558
1610
  }
@@ -1682,6 +1734,7 @@ async function migrateLegacyState(persistenceOptions, client) {
1682
1734
  )
1683
1735
  if (!migrationLease) return
1684
1736
  try {
1737
+ if (currentRuntime().disposed) return
1685
1738
  if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1686
1739
 
1687
1740
  const state = await readPersistedStateFile(candidate.stateFilePath, client)
@@ -1735,8 +1788,22 @@ async function migrateLegacyState(persistenceOptions, client) {
1735
1788
  }
1736
1789
 
1737
1790
  // A fresh project has no aggregate or legacy state. Mark the namespace so a
1738
- // later session does not repeatedly probe global fallback paths.
1739
- await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1791
+ // later session does not repeatedly probe global fallback paths. Separate
1792
+ // session processes must still serialize this shared marker: POSIX rename
1793
+ // replaces an existing destination, while Windows can reject that race.
1794
+ if (currentRuntime().disposed) return
1795
+ const freshMigrationLease = await acquireMigrationLease(
1796
+ persistenceOptions.stateFilePath,
1797
+ persistenceOptions.migrationMarkerPath,
1798
+ )
1799
+ if (!freshMigrationLease) return
1800
+ try {
1801
+ if (currentRuntime().disposed) return
1802
+ if (await pathExists(persistenceOptions.migrationMarkerPath)) return
1803
+ await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
1804
+ } finally {
1805
+ await freshMigrationLease.release()
1806
+ }
1740
1807
  }
1741
1808
 
1742
1809
  async function loadPersistedSessionState(persistence, client, sessionID) {
@@ -1777,7 +1844,12 @@ async function reconstructFromLedger(persistenceOptions, client, onlySessionID =
1777
1844
  )
1778
1845
  if (!reconstructed.length) return "missing"
1779
1846
 
1780
- if (onlySessionID) clearSessionRuntimeState(onlySessionID)
1847
+ if (onlySessionID) {
1848
+ clearSessionRuntimeState(onlySessionID, {
1849
+ preserveCommandSecurity: true,
1850
+ preserveExecutionContext: true,
1851
+ })
1852
+ }
1781
1853
  else clearRuntimeState()
1782
1854
  const focusCandidates = new Map()
1783
1855
  for (const stub of reconstructed) {
@@ -1847,24 +1919,46 @@ async function persistState(persistence, client, sessionID) {
1847
1919
  }
1848
1920
  }
1849
1921
 
1850
- async function logPluginError(client, message, error) {
1922
+ function dispatchAdvisoryHostCall(call, onFailure = () => {}) {
1923
+ try {
1924
+ // Host notices are diagnostic only. Start the SDK request immediately,
1925
+ // contain both synchronous and asynchronous failures, and never let a
1926
+ // stalled host promise retain a persistence lease or block goal controls.
1927
+ void Promise.resolve(call()).catch(onFailure)
1928
+ } catch (error) {
1929
+ onFailure(error)
1930
+ }
1931
+ }
1932
+
1933
+ async function logPluginMessage(client, level, message, error) {
1934
+ const fallback = () => {
1935
+ const logger = level === "warn" ? console.warn : console.error
1936
+ logger("[goal-plugin]", message, error || "")
1937
+ }
1851
1938
  if (client?.app?.log) {
1852
- try {
1853
- await client.app.log({
1939
+ return dispatchAdvisoryHostCall(
1940
+ () => client.app.log({
1854
1941
  body: {
1855
1942
  service: "opencode-goal-plugin",
1856
- level: "error",
1943
+ level,
1857
1944
  message,
1858
- extra: { error: error?.message || error?.name || String(error) },
1945
+ ...(error === undefined
1946
+ ? {}
1947
+ : { extra: { error: error?.message || error?.name || String(error) } }),
1859
1948
  },
1860
- })
1861
- return
1862
- } catch {
1863
- // Logging must never poison persistence or leak an acquired lease.
1864
- }
1949
+ }),
1950
+ fallback,
1951
+ )
1865
1952
  }
1953
+ fallback()
1954
+ }
1955
+
1956
+ async function logPluginError(client, message, error) {
1957
+ return logPluginMessage(client, "error", message, error)
1958
+ }
1866
1959
 
1867
- console.error("[goal-plugin]", message, error || "")
1960
+ async function logPluginWarning(client, message) {
1961
+ return logPluginMessage(client, "warn", message)
1868
1962
  }
1869
1963
 
1870
1964
  function parseGoalArguments(args, defaults) {
@@ -2459,7 +2553,14 @@ function pluginMessageMatches(message, kind, correlationID) {
2459
2553
  return Boolean(correlationID) && pluginMessageCorrelationID(message, kind) === correlationID
2460
2554
  }
2461
2555
 
2462
- function rememberOwnedPluginMessage(message, sessionID, kind, correlationID, policy = "") {
2556
+ function rememberOwnedPluginMessage(
2557
+ message,
2558
+ sessionID,
2559
+ kind,
2560
+ correlationID,
2561
+ policy = "",
2562
+ passive = false,
2563
+ ) {
2463
2564
  const id = messageID(message)
2464
2565
  if (!id) return
2465
2566
  setBoundedMessageValue(currentRuntime().ownedPluginMessages, id, {
@@ -2467,9 +2568,34 @@ function rememberOwnedPluginMessage(message, sessionID, kind, correlationID, pol
2467
2568
  kind,
2468
2569
  correlationID,
2469
2570
  ...(policy ? { policy } : {}),
2571
+ ...(passive ? { passive: true } : {}),
2470
2572
  })
2471
2573
  }
2472
2574
 
2575
+ function suppressControlCommandAssistant(message) {
2576
+ const currentMessageID = messageID(message)
2577
+ const currentSessionID = messageSessionID(message)
2578
+ if (!currentMessageID || !currentSessionID) return false
2579
+ const runtime = currentRuntime()
2580
+ const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
2581
+ const isControlCommandAssistant =
2582
+ messageRole(message) === "assistant" &&
2583
+ parentOwner?.kind === "command" &&
2584
+ parentOwner?.policy === "control" &&
2585
+ parentOwner?.sessionID === currentSessionID
2586
+ if (!isControlCommandAssistant) return false
2587
+ // A control command may produce several assistant messages (for example, a
2588
+ // blocked tool-call step followed by a final report). Authenticate each
2589
+ // response through its owned parent user message and suppress it immediately
2590
+ // so later idle processing cannot treat it as goal progress or completion.
2591
+ setBoundedMessageValue(
2592
+ runtime.suppressedCommandAssistants,
2593
+ currentMessageID,
2594
+ currentSessionID,
2595
+ )
2596
+ return parentOwner?.passive === true ? "passive" : "control"
2597
+ }
2598
+
2473
2599
  function isOwnedPluginMessage(message, kind, ownedMessages = currentRuntime().ownedPluginMessages) {
2474
2600
  const id = messageID(message)
2475
2601
  const correlationID = pluginMessageCorrelationID(message, kind)
@@ -2522,17 +2648,27 @@ function isPluginGeneratedMessage(message, ownedMessages = currentRuntime().owne
2522
2648
  )
2523
2649
  }
2524
2650
 
2651
+ function pruneExpiredPendingCommandTurns(sessionID, now = Date.now()) {
2652
+ const runtime = currentRuntime()
2653
+ const pending = runtime.pendingCommandTurns.get(sessionID)
2654
+ if (pending) {
2655
+ for (const [id, turn] of pending) {
2656
+ if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
2657
+ }
2658
+ if (pending.size === 0) runtime.pendingCommandTurns.delete(sessionID)
2659
+ }
2660
+
2661
+ }
2662
+
2525
2663
  function registerPendingCommandTurn(sessionID, output) {
2526
2664
  const runtime = currentRuntime()
2527
2665
  const now = Date.now()
2666
+ pruneExpiredPendingCommandTurns(sessionID, now)
2528
2667
  let pending = runtime.pendingCommandTurns.get(sessionID)
2529
2668
  if (!pending) {
2530
2669
  pending = new Map()
2531
2670
  runtime.pendingCommandTurns.set(sessionID, pending)
2532
2671
  }
2533
- for (const [id, turn] of pending) {
2534
- if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
2535
- }
2536
2672
  while (pending.size >= MAX_PENDING_COMMAND_TURNS_PER_SESSION) {
2537
2673
  pending.delete(pending.keys().next().value)
2538
2674
  }
@@ -2930,12 +3066,70 @@ function agentToolSessionID(ctx) {
2930
3066
  // helper's unrelated SDK/effect dependency graph in every consumer project.
2931
3067
  const bundledToolHelper = Object.assign((definition) => definition, { schema: z })
2932
3068
 
2933
- function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
3069
+ function sessionOwnedElsewhereMessage(
3070
+ commandName = "goal",
3071
+ commandRegistered = true,
3072
+ reason = "owned_elsewhere",
3073
+ ) {
3074
+ const retryTarget = commandRegistered
3075
+ ? `\`/${commandName} status\``
3076
+ : "the `goal_status` tool"
3077
+ if (reason === "legacy_lock") {
3078
+ return (
3079
+ "Goal controls are unavailable because this session has an older or incomplete persistence lease. " +
3080
+ "No goal state was read or changed here. Ordinary chat remains available. " +
3081
+ "Close every OpenCode process using this session and upgrade them first. If the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or open a fork with `opencode --continue --fork`, " +
3082
+ `then retry ${retryTarget}.`
3083
+ )
3084
+ }
3085
+ return (
3086
+ "Goal controls are unavailable in this OpenCode instance because another process owns this session's goal workflow. " +
3087
+ "No goal state was read or changed here. Ordinary chat remains available. " +
3088
+ `Close the owning process or open a fork with \`opencode --continue --fork\`, then retry ${retryTarget}.`
3089
+ )
3090
+ }
3091
+
3092
+ function inactiveGoalToolResult(
3093
+ loadResult,
3094
+ commandName = "goal",
3095
+ disposed = false,
3096
+ commandRegistered = true,
3097
+ ) {
3098
+ if (disposed || loadResult?.kind === "disposed") {
3099
+ return goalToolFailure("plugin_disposed", "The goal plugin is no longer active in this process.")
3100
+ }
3101
+ if (loadResult?.kind === "passive") {
3102
+ return goalToolFailure(
3103
+ SESSION_OWNED_ELSEWHERE,
3104
+ sessionOwnedElsewhereMessage(commandName, commandRegistered, loadResult.reason),
3105
+ )
3106
+ }
3107
+ return null
3108
+ }
3109
+
3110
+ function buildAgentTools(
3111
+ toolHelper,
3112
+ handlers,
3113
+ ensureSessionLoaded = async () => ACTIVE_PERSISTENCE_DISABLED,
3114
+ commandName = "goal",
3115
+ isDisposed = () => false,
3116
+ commandRegistered = true,
3117
+ ) {
2934
3118
  const schema = toolHelper.schema
2935
3119
  const run = (handler) => async (args, ctx) => {
2936
3120
  const sessionID = agentToolSessionID(ctx)
2937
3121
  if (!sessionID) return "No session id available for the goal tool."
2938
- await ensureSessionLoaded(sessionID)
3122
+ const loadResult = await ensureSessionLoaded(sessionID, {
3123
+ retryPassive: true,
3124
+ executionContext: ctx,
3125
+ })
3126
+ const unavailable = inactiveGoalToolResult(
3127
+ loadResult,
3128
+ commandName,
3129
+ isDisposed(),
3130
+ commandRegistered,
3131
+ )
3132
+ if (unavailable) return unavailable.message
2939
3133
  return handler(sessionID, args || {})
2940
3134
  }
2941
3135
  // Canonical tools use a small, versioned machine-readable envelope. Keep the
@@ -2949,7 +3143,17 @@ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () =>
2949
3143
  goalToolFailure("missing_session", "No session id available for the goal tool."),
2950
3144
  )
2951
3145
  }
2952
- await ensureSessionLoaded(sessionID)
3146
+ const loadResult = await ensureSessionLoaded(sessionID, {
3147
+ retryPassive: true,
3148
+ executionContext: ctx,
3149
+ })
3150
+ const unavailable = inactiveGoalToolResult(
3151
+ loadResult,
3152
+ commandName,
3153
+ isDisposed(),
3154
+ commandRegistered,
3155
+ )
3156
+ if (unavailable) return serializeGoalToolResult(operation, unavailable)
2953
3157
  return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
2954
3158
  }
2955
3159
 
@@ -3118,24 +3322,24 @@ function formatGoalList(sessionID, commandName = "goal") {
3118
3322
  // once a non-prompting message API is available.
3119
3323
  async function defaultAuditMessenger(client, sessionID, text) {
3120
3324
  if (client?.app?.log) {
3121
- await client.app.log({
3325
+ dispatchAdvisoryHostCall(() => client.app.log({
3122
3326
  body: {
3123
3327
  service: "opencode-goal-plugin",
3124
3328
  level: "info",
3125
3329
  message: text,
3126
3330
  extra: { sessionID, kind: "goal-audit" },
3127
3331
  },
3128
- })
3332
+ }))
3129
3333
  }
3130
3334
  if (client?.tui?.showToast) {
3131
- await client.tui.showToast({
3335
+ dispatchAdvisoryHostCall(() => client.tui.showToast({
3132
3336
  body: {
3133
3337
  title: "Goal workflow",
3134
3338
  message: summarizeText(text, 500),
3135
3339
  variant: /rejected|failed|blocked/i.test(text) ? "warning" : "info",
3136
3340
  duration: 6000,
3137
3341
  },
3138
- })
3342
+ }))
3139
3343
  }
3140
3344
  }
3141
3345
 
@@ -3295,11 +3499,69 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3295
3499
  return persistence.persistChain
3296
3500
  }
3297
3501
 
3298
- const ensureSessionLoaded = async (sessionID) => {
3299
- if (!persistenceOptions.persistState || !sessionID) return true
3502
+ const passiveLoadResult = (entry) => ({
3503
+ kind: "passive",
3504
+ code: SESSION_OWNED_ELSEWHERE,
3505
+ reason: entry.reason,
3506
+ owner: entry.owner,
3507
+ retryAt: entry.retryAt,
3508
+ })
3509
+
3510
+ const enterPassiveSession = async (sessionID, error) => {
3511
+ const previous = runtime.passiveSessions.get(sessionID)
3512
+ clearSessionRuntimeState(sessionID, {
3513
+ preserveCommandSecurity: Boolean(previous),
3514
+ preserveExecutionContext: true,
3515
+ })
3516
+ const entry = {
3517
+ code: SESSION_OWNED_ELSEWHERE,
3518
+ reason: error.reason,
3519
+ owner: error.owner,
3520
+ firstObservedAt: previous?.firstObservedAt || Date.now(),
3521
+ retryAt: Date.now() + PASSIVE_SESSION_RETRY_MS,
3522
+ warned: true,
3523
+ }
3524
+ runtime.passiveSessions.set(sessionID, entry)
3525
+ if (!previous?.warned) {
3526
+ const owner = entry.owner?.pid && entry.owner?.hostname
3527
+ ? `pid ${entry.owner.pid} on ${entry.owner.hostname}`
3528
+ : "another process"
3529
+ const warning = entry.reason === "legacy_lock"
3530
+ ? "Goal controls are passive for this session because its persistence lease is from an older release or is incomplete. Ordinary chat remains available. Close every OpenCode process using this session and upgrade them; if the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or fork the session before retrying goal controls."
3531
+ : `Goal controls are passive for this session because ${owner} owns its persistence lease. Ordinary chat remains available; close the owner or fork the session before retrying goal controls.`
3532
+ // Host logging is advisory. A broken or backpressured logger must not
3533
+ // turn passive mode back into the session-wide hang it is meant to
3534
+ // prevent, and the contained rejection avoids an unhandled promise.
3535
+ void logPluginWarning(
3536
+ client,
3537
+ warning,
3538
+ ).catch(() => {})
3539
+ }
3540
+ return passiveLoadResult(entry)
3541
+ }
3542
+
3543
+ const ensureSessionLoaded = async (
3544
+ sessionID,
3545
+ { retryPassive = false, executionContext, freshCommandBoundary = false } = {},
3546
+ ) => {
3547
+ if (runtime.disposed) return PLUGIN_DISPOSED
3548
+ rememberSessionExecutionContext(sessionID, executionContext)
3549
+ if (!persistenceOptions.persistState || !sessionID) return ACTIVE_PERSISTENCE_DISABLED
3300
3550
  const existingLoad = runtime.sessionLoadPromises.get(sessionID)
3301
3551
  if (existingLoad) return existingLoad
3302
- if (runtime.sessionPersistence.has(sessionID)) return true
3552
+ if (runtime.sessionPersistence.has(sessionID)) return ACTIVE_PERSISTENCE_OWNED
3553
+
3554
+ const passive = runtime.passiveSessions.get(sessionID)
3555
+ pruneExpiredPendingCommandTurns(sessionID)
3556
+ const commandTurnInFlight =
3557
+ runtime.pendingCommandTurns.has(sessionID) ||
3558
+ (!freshCommandBoundary && runtime.activeCommandTurns.has(sessionID))
3559
+ if (
3560
+ passive &&
3561
+ (!retryPassive || commandTurnInFlight || Date.now() < passive.retryAt)
3562
+ ) {
3563
+ return passiveLoadResult(passive)
3564
+ }
3303
3565
 
3304
3566
  const load = (async () => {
3305
3567
  const paths = sessionPathsFor(persistenceOptions, sessionID)
@@ -3307,20 +3569,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3307
3569
  ...persistenceOptions,
3308
3570
  stateFilePath: paths.stateFilePath,
3309
3571
  })
3310
- const lease = await acquirePersistenceLease(paths.stateFilePath)
3572
+ let lease
3573
+ try {
3574
+ lease = await acquirePersistenceLease(paths.stateFilePath)
3575
+ } catch (error) {
3576
+ if (!isPersistenceLeaseContendedError(error)) throw error
3577
+ return enterPassiveSession(sessionID, error)
3578
+ }
3579
+ const releaseDisposedSession = async () => {
3580
+ runtime.sessionPersistence.delete(sessionID)
3581
+ await lease.release().catch(() => false)
3582
+ return PLUGIN_DISPOSED
3583
+ }
3584
+ if (runtime.disposed) return releaseDisposedSession()
3311
3585
  const persistence = {
3312
3586
  ...persistenceOptions,
3313
3587
  ...paths,
3314
3588
  persistChain: Promise.resolve(true),
3315
3589
  lease,
3316
3590
  }
3591
+ runtime.passiveSessions.delete(sessionID)
3317
3592
  runtime.sessionPersistence.set(sessionID, persistence)
3318
3593
  try {
3319
3594
  await migrateLegacyState(persistenceOptions, client)
3595
+ if (runtime.disposed) return releaseDisposedSession()
3320
3596
  const status = await loadPersistedSessionState(persistence, client, sessionID)
3597
+ if (runtime.disposed) return releaseDisposedSession()
3321
3598
  pruneGoalResults(defaultGoalOptions)
3322
3599
  if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID)
3323
- return true
3600
+ if (runtime.disposed) return releaseDisposedSession()
3601
+ return ACTIVE_PERSISTENCE_OWNED
3324
3602
  } catch (error) {
3325
3603
  runtime.sessionPersistence.delete(sessionID)
3326
3604
  await lease.release().catch(() => false)
@@ -3525,6 +3803,42 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3525
3803
  return goal
3526
3804
  }
3527
3805
 
3806
+ const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => {
3807
+ const runtime = currentRuntime()
3808
+ const activeCommandTurn = runtime.activeCommandTurns.get(sessionID)
3809
+ if (!activeCommandTurn) return { ready: true, messages: null }
3810
+
3811
+ const commandHostMessages = await sessionApi.messages(sessionID, {
3812
+ limit: messageLimit,
3813
+ })
3814
+ if (runtime.disposed) return { ready: false, messages: null }
3815
+ const commandMessages = Array.isArray(commandHostMessages)
3816
+ ? commandHostMessages.slice(-messageLimit)
3817
+ : []
3818
+ if (runtime.activeCommandTurns.get(sessionID) !== activeCommandTurn) {
3819
+ return { ready: false, messages: commandMessages }
3820
+ }
3821
+ const commandAssistant = findLatestAssistantMessage(commandMessages)
3822
+ if (
3823
+ !commandAssistant ||
3824
+ messageParentID(commandAssistant) !== activeCommandTurn.messageID
3825
+ ) {
3826
+ return { ready: false, messages: commandMessages }
3827
+ }
3828
+ if (activeCommandTurn.policy === "control") {
3829
+ const commandAssistantID = messageID(commandAssistant)
3830
+ if (commandAssistantID) {
3831
+ setBoundedMessageValue(
3832
+ runtime.suppressedCommandAssistants,
3833
+ commandAssistantID,
3834
+ sessionID,
3835
+ )
3836
+ }
3837
+ }
3838
+ runtime.activeCommandTurns.delete(sessionID)
3839
+ return { ready: true, messages: commandMessages }
3840
+ }
3841
+
3528
3842
  const hooks = {
3529
3843
  config: async (config) => {
3530
3844
  applyNativeGoalConfig(config, {
@@ -3535,20 +3849,29 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3535
3849
  },
3536
3850
  "chat.params": async (input) => {
3537
3851
  if (!input?.sessionID) return
3538
- await ensureSessionLoaded(input.sessionID)
3539
- const context = normalizeExecutionContext({
3540
- agent: input.agent,
3541
- model: input.model,
3542
- variant: input?.message?.model?.variant,
3852
+ const loadResult = await ensureSessionLoaded(input.sessionID, {
3853
+ executionContext: input,
3543
3854
  })
3544
- if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context)
3855
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
3856
+ rememberSessionExecutionContext(
3857
+ input.sessionID,
3858
+ {
3859
+ agent: input.agent,
3860
+ model: input.model,
3861
+ variant:
3862
+ input.variant ?? input?.model?.variant ?? input?.message?.model?.variant,
3863
+ },
3864
+ { replace: true },
3865
+ )
3545
3866
  },
3546
3867
  "chat.message": async (input, output) => {
3547
3868
  const sessionID = input?.sessionID
3548
3869
  if (!sessionID) return
3549
- await ensureSessionLoaded(sessionID)
3550
- const context = normalizeExecutionContext(input)
3551
- if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
3870
+ const loadResult = await ensureSessionLoaded(sessionID, {
3871
+ executionContext: input,
3872
+ })
3873
+ if (currentRuntime().disposed) return
3874
+ rememberSessionExecutionContext(sessionID, input, { replace: true })
3552
3875
 
3553
3876
  const message = {
3554
3877
  info: isPlainObject(output?.message)
@@ -3581,6 +3904,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3581
3904
  "command",
3582
3905
  commandTurn.id,
3583
3906
  commandTurn.policy,
3907
+ commandTurn.passive === true,
3584
3908
  )
3585
3909
  return
3586
3910
  }
@@ -3590,6 +3914,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3590
3914
  // in flight; public synthetic/metadata fields alone are never trusted.
3591
3915
  runtime.pendingCommandTurns.delete(sessionID)
3592
3916
  runtime.activeCommandTurns.delete(sessionID)
3917
+ if (loadResult.kind !== "active") return
3593
3918
  const continuationID = activeContinues.get(sessionID)
3594
3919
  if (
3595
3920
  currentMessageID &&
@@ -3615,6 +3940,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3615
3940
  const sessionID = input?.sessionID
3616
3941
  if (!sessionID) return
3617
3942
  await ensureSessionLoaded(sessionID)
3943
+ if (currentRuntime().disposed) return
3618
3944
  if (currentRuntime().activeCommandTurns.get(sessionID)?.policy !== "control") return
3619
3945
  throw new Error(
3620
3946
  `This /${commandName} control command has already been handled. Tool "${input?.tool || "unknown"}" was blocked because no tool calls are allowed while its result is being reported. Wait for a separate user turn before using tools or modifying work or goal state.`,
@@ -3625,8 +3951,26 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3625
3951
 
3626
3952
  const sessionID = input.sessionID
3627
3953
  if (!sessionID) return
3628
- await ensureSessionLoaded(sessionID)
3629
- registerPendingCommandTurn(sessionID, output)
3954
+ // A fresh slash command is an authenticated boundary that may retry a
3955
+ // passive lease without waiting forever for an orphaned older reply.
3956
+ // Keep the old active guard installed during the asynchronous load so
3957
+ // tools from that older turn remain blocked; accepting this new command
3958
+ // in chat.message atomically replaces the guard.
3959
+ const loadResult = await ensureSessionLoaded(sessionID, {
3960
+ retryPassive: true,
3961
+ freshCommandBoundary: true,
3962
+ })
3963
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
3964
+ const commandTurn = registerPendingCommandTurn(sessionID, output)
3965
+
3966
+ if (loadResult.kind === "passive") {
3967
+ commandTurn.passive = true
3968
+ replaceCommandOutputText(
3969
+ output,
3970
+ sessionOwnedElsewhereMessage(commandName, true, loadResult.reason),
3971
+ )
3972
+ return
3973
+ }
3630
3974
 
3631
3975
  if (typeof input.arguments !== "string") {
3632
3976
  replaceCommandOutputText(output, "Goal command arguments must be text.")
@@ -4061,9 +4405,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4061
4405
 
4062
4406
  event: async ({ event }) => {
4063
4407
  const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
4064
- if (eventSessionID) await ensureSessionLoaded(eventSessionID)
4408
+ const loadResult = eventSessionID
4409
+ ? await ensureSessionLoaded(eventSessionID)
4410
+ : ACTIVE_PERSISTENCE_DISABLED
4411
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4412
+ const passive = loadResult.kind === "passive"
4065
4413
 
4066
- if (event?.type === "session.status") {
4414
+ if (!passive && event?.type === "session.status") {
4067
4415
  const sessionID = getSessionID(event)
4068
4416
  const status = event?.properties?.status?.type || event?.data?.status?.type
4069
4417
  if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
@@ -4071,22 +4419,42 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4071
4419
 
4072
4420
  if (event?.type === "session.updated") {
4073
4421
  const sessionID = getSessionID(event)
4074
- const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info)
4075
- if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
4422
+ rememberSessionExecutionContext(
4423
+ sessionID,
4424
+ event?.properties?.info || event?.data?.info,
4425
+ )
4076
4426
  }
4077
4427
 
4078
- if (event?.type === "message.updated") {
4428
+ if (!passive && event?.type === "message.updated") {
4079
4429
  const message = messageInfoFromEvent(event)
4080
4430
  if (messageRole(message) === "user") {
4081
- const context = normalizeExecutionContext(message)
4082
4431
  const sessionID = messageSessionID(message) || getSessionID(event)
4083
- if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
4432
+ rememberSessionExecutionContext(sessionID, message)
4084
4433
  }
4085
4434
  }
4086
4435
 
4436
+ const updatedMessage = event?.type === "message.updated"
4437
+ ? messageInfoFromEvent(event)
4438
+ : null
4439
+ const controlCommandAssistant = updatedMessage
4440
+ ? suppressControlCommandAssistant(updatedMessage)
4441
+ : false
4442
+
4087
4443
  const terminal = terminalEvent(event)
4088
4444
  if (terminal?.sessionID) {
4089
4445
  const runtime = currentRuntime()
4446
+ if (controlCommandAssistant) {
4447
+ // A provider error on a plugin-owned control reply belongs to that
4448
+ // read-only command turn, not to whichever goal may be active now.
4449
+ // This is especially important after passive takeover: a delayed
4450
+ // denial reply from the old lease epoch must not pause a newly
4451
+ // resumed goal. Retire only the exact active guard it answers.
4452
+ const activeCommandTurn = runtime.activeCommandTurns.get(terminal.sessionID)
4453
+ if (activeCommandTurn?.messageID === messageParentID(updatedMessage)) {
4454
+ runtime.activeCommandTurns.delete(terminal.sessionID)
4455
+ }
4456
+ return
4457
+ }
4090
4458
  const pendingTurns = runtime.pendingCommandTurns.get(terminal.sessionID)
4091
4459
  const resolvingCommandTurn = [...(pendingTurns?.values() || [])].reverse().find(
4092
4460
  (turn) => turn.preservedFileCount > 0,
@@ -4110,6 +4478,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4110
4478
  }
4111
4479
  if (!resolvingCommandAttachments) runtime.pendingCommandTurns.delete(terminal.sessionID)
4112
4480
  runtime.activeCommandTurns.delete(terminal.sessionID)
4481
+ if (passive) return
4113
4482
  await pauseActiveGoal(terminal.sessionID, {
4114
4483
  ...(resolvingCommandAttachments
4115
4484
  ? {
@@ -4126,6 +4495,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4126
4495
  return
4127
4496
  }
4128
4497
 
4498
+ if (event?.type === "message.updated") {
4499
+ if (passive || controlCommandAssistant === "passive") return
4500
+ }
4501
+
4502
+ if (passive) {
4503
+ if (isIdleEvent(event) && eventSessionID) {
4504
+ // A session-scoped idle can be stale or unrelated. Keep the passive
4505
+ // command guard until the latest assistant is proven to answer the
4506
+ // plugin-owned denial turn, matching the active-mode correlation
4507
+ // contract below.
4508
+ await retireCompletedCommandTurnOnIdle(
4509
+ eventSessionID,
4510
+ defaultGoalOptions.maxRecentMessages,
4511
+ )
4512
+ }
4513
+ return
4514
+ }
4515
+
4129
4516
  if (event?.type === "session.compacted") {
4130
4517
  const sessionID = getSessionID(event)
4131
4518
  const goal = goalStates.get(sessionID)
@@ -4144,25 +4531,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4144
4531
  if (!currentMessageID) return
4145
4532
  const currentSessionID = messageSessionID(message)
4146
4533
  const runtime = currentRuntime()
4147
- const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
4148
- const isControlCommandAssistant =
4149
- messageRole(message) === "assistant" &&
4150
- parentOwner?.kind === "command" &&
4151
- parentOwner?.policy === "control" &&
4152
- parentOwner?.sessionID === currentSessionID
4153
- if (isControlCommandAssistant) {
4154
- // A control command may produce several assistant messages (for
4155
- // example, a blocked tool-call step followed by a final report), and
4156
- // another plugin turn may overlap before all message.updated events
4157
- // arrive. Authenticate each response through its owned parent user
4158
- // message instead of relying on the session's single latest-command
4159
- // slot, then suppress it immediately for later idle processing.
4160
- setBoundedMessageValue(
4161
- runtime.suppressedCommandAssistants,
4162
- currentMessageID,
4163
- currentSessionID,
4164
- )
4165
- }
4166
4534
 
4167
4535
  const goal = goalStates.get(currentSessionID)
4168
4536
  if (!goal) return
@@ -4247,37 +4615,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4247
4615
  // remain suppressed in a bounded map so a later duplicate idle cannot
4248
4616
  // reinterpret the same report as goal progress or completion.
4249
4617
  const runtime = currentRuntime()
4250
- const activeCommandTurn = runtime.activeCommandTurns.get(sessionID)
4251
- let commandMessages = null
4252
- if (activeCommandTurn) {
4253
- const commandMessageLimit =
4254
- goalStates.get(sessionID)?.options.maxRecentMessages ||
4255
- defaultGoalOptions.maxRecentMessages
4256
- const commandHostMessages = await sessionApi.messages(sessionID, {
4257
- limit: commandMessageLimit,
4258
- })
4259
- commandMessages = Array.isArray(commandHostMessages)
4260
- ? commandHostMessages.slice(-commandMessageLimit)
4261
- : []
4262
- const commandAssistant = findLatestAssistantMessage(commandMessages)
4263
- if (
4264
- !commandAssistant ||
4265
- messageParentID(commandAssistant) !== activeCommandTurn.messageID
4266
- ) {
4267
- return
4268
- }
4269
- if (activeCommandTurn.policy === "control") {
4270
- const commandAssistantID = messageID(commandAssistant)
4271
- if (commandAssistantID) {
4272
- setBoundedMessageValue(
4273
- runtime.suppressedCommandAssistants,
4274
- commandAssistantID,
4275
- sessionID,
4276
- )
4277
- }
4278
- }
4279
- runtime.activeCommandTurns.delete(sessionID)
4280
- }
4618
+ const commandMessageLimit =
4619
+ goalStates.get(sessionID)?.options.maxRecentMessages ||
4620
+ defaultGoalOptions.maxRecentMessages
4621
+ const commandTurnState = await retireCompletedCommandTurnOnIdle(
4622
+ sessionID,
4623
+ commandMessageLimit,
4624
+ )
4625
+ if (!commandTurnState.ready) return
4626
+ const commandMessages = commandTurnState.messages
4281
4627
 
4282
4628
  const goal = goalStates.get(sessionID)
4283
4629
  if (!goal || goal.stopped || activeContinues.has(sessionID)) return
@@ -4809,11 +5155,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4809
5155
 
4810
5156
  "experimental.chat.system.transform": async (input, output) => {
4811
5157
  if (!input.sessionID) return
4812
- await ensureSessionLoaded(input.sessionID)
5158
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5159
+ if (currentRuntime().disposed || loadResult.kind === "disposed") return
4813
5160
 
4814
5161
  const activeCommandTurn = currentRuntime().activeCommandTurns.get(input.sessionID)
4815
5162
  const commandGuarded = activeCommandTurn?.policy === "control"
4816
- const goal = goalStates.get(input.sessionID)
5163
+ const goal = loadResult.kind === "active" ? goalStates.get(input.sessionID) : null
4817
5164
  if (!goal && !commandGuarded) return
4818
5165
  const blockID = goal?.goalId || `command-${activeCommandTurn.id}`
4819
5166
  const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
@@ -4870,7 +5217,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4870
5217
 
4871
5218
  "experimental.session.compacting": async (input, output) => {
4872
5219
  if (!input?.sessionID || !output) return
4873
- await ensureSessionLoaded(input.sessionID)
5220
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5221
+ if (currentRuntime().disposed || loadResult.kind !== "active") return
4874
5222
  const goal = goalStates.get(input.sessionID)
4875
5223
  if (!goal) return
4876
5224
  const context = buildCompactionContext(goal)
@@ -4890,7 +5238,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4890
5238
  // auto-continue to avoid two continuations racing after a compaction.
4891
5239
  // Paused/stopped goals leave the native behavior untouched.
4892
5240
  if (!input?.sessionID || !output) return
4893
- await ensureSessionLoaded(input.sessionID)
5241
+ const loadResult = await ensureSessionLoaded(input.sessionID)
5242
+ if (currentRuntime().disposed || loadResult.kind !== "active") return
4894
5243
  const goal = goalStates.get(input.sessionID)
4895
5244
  if (!goal || goal.stopped) return
4896
5245
  output.enabled = false
@@ -4907,7 +5256,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4907
5256
  // makes this deterministic for normal npm installs; `registerTools: false`
4908
5257
  // remains the explicit opt-out.
4909
5258
  if (pluginOptions.registerTools !== false) {
4910
- hooks.tool = buildAgentTools(bundledToolHelper, agentToolHandlers, ensureSessionLoaded)
5259
+ hooks.tool = buildAgentTools(
5260
+ bundledToolHelper,
5261
+ agentToolHandlers,
5262
+ ensureSessionLoaded,
5263
+ commandName,
5264
+ () => runtime.disposed,
5265
+ registerCommand,
5266
+ )
4911
5267
  }
4912
5268
 
4913
5269
  return hooks
@@ -4987,6 +5343,7 @@ export default {
4987
5343
  }
4988
5344
 
4989
5345
  export const testInternals = {
5346
+ commandTurnTtlMs: COMMAND_TURN_TTL_MS,
4990
5347
  activeGoal,
4991
5348
  agentToolSessionID,
4992
5349
  buildAgentToolHandlers,
@@ -5042,6 +5399,7 @@ export const testInternals = {
5042
5399
  parseTokenBudget,
5043
5400
  pruneGoalResults,
5044
5401
  resolveStateFilePath,
5402
+ runtimeSessionDiagnostics,
5045
5403
  stopReason,
5046
5404
  xdgStateFilePath,
5047
5405
  }