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.
- package/CHANGELOG.md +6 -0
- package/README.md +9 -4
- package/docs/compatibility.md +29 -1
- package/package.json +1 -1
- package/src/goal-plugin.js +473 -115
- package/src/persistence-lease.js +594 -59
package/src/goal-plugin.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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)
|
|
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 (!
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1939
|
+
return dispatchAdvisoryHostCall(
|
|
1940
|
+
() => client.app.log({
|
|
1854
1941
|
body: {
|
|
1855
1942
|
service: "opencode-goal-plugin",
|
|
1856
|
-
level
|
|
1943
|
+
level,
|
|
1857
1944
|
message,
|
|
1858
|
-
|
|
1945
|
+
...(error === undefined
|
|
1946
|
+
? {}
|
|
1947
|
+
: { extra: { error: error?.message || error?.name || String(error) } }),
|
|
1859
1948
|
},
|
|
1860
|
-
})
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3299
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
3551
|
-
|
|
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
|
-
|
|
3629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4075
|
-
|
|
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
|
-
|
|
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
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
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(
|
|
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
|
}
|