opencode-goal-plugin 0.4.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -3
- package/CONTRIBUTING.md +8 -7
- package/README.md +98 -13
- package/SECURITY.md +29 -1
- package/demo/README.md +86 -0
- package/demo/opencode.json +11 -0
- package/demo/package.json +10 -0
- package/demo/src/math.js +3 -0
- package/demo/test/math.test.js +11 -0
- package/docs/providers.md +84 -0
- package/index.d.ts +344 -0
- package/package.json +15 -5
- package/scripts/behavior-benchmark.mjs +272 -0
- package/scripts/packed-host-contract.mjs +160 -0
- package/scripts/verify.mjs +125 -0
- package/src/completion-claim.js +127 -0
- package/src/goal-plugin.js +604 -137
- package/src/goal-tool-result.js +18 -0
- package/src/native-agent-config.js +69 -0
- package/src/opencode-session-api.js +100 -0
- package/src/persistence-lease.js +82 -0
package/src/goal-plugin.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
|
-
import {
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
3
|
+
import { promises as fs, appendFileSync, mkdirSync, statSync, renameSync, rmSync, chmodSync } from "node:fs"
|
|
3
4
|
import { homedir } from "node:os"
|
|
4
5
|
import { dirname, join } from "node:path"
|
|
6
|
+
import { createOpenCodeSessionApi } from "./opencode-session-api.js"
|
|
7
|
+
import { applyNativeGoalConfig } from "./native-agent-config.js"
|
|
8
|
+
import { serializeCompletionClaim } from "./completion-claim.js"
|
|
9
|
+
import { goalToolFailure, goalToolSuccess, serializeGoalToolResult } from "./goal-tool-result.js"
|
|
10
|
+
import { acquirePersistenceLease } from "./persistence-lease.js"
|
|
5
11
|
|
|
6
12
|
const STATE_FILE_VERSION = 1
|
|
7
13
|
// Default state now follows the project: <cwd>/.opencode/goals/state.json.
|
|
@@ -21,6 +27,13 @@ function legacyHomeStateFilePath(env = process.env) {
|
|
|
21
27
|
const MAX_HISTORY_ENTRIES = 20
|
|
22
28
|
const MAX_CHECKPOINTS = 5
|
|
23
29
|
const CHECKPOINT_CHAR_LIMIT = 280
|
|
30
|
+
const MAX_GOAL_OBJECTIVE_LENGTH = 4000
|
|
31
|
+
const MAX_GOAL_META_LENGTH = 2000
|
|
32
|
+
const MAX_GOAL_BLOCKER_LENGTH = 2000
|
|
33
|
+
const MAX_LEGACY_EVIDENCE_LENGTH = 8000
|
|
34
|
+
const DEFAULT_LEDGER_MAX_BYTES = 2 * 1024 * 1024
|
|
35
|
+
const DEFAULT_LEDGER_RETENTION_FILES = 3
|
|
36
|
+
const MAX_LEDGER_LINE_BYTES = 16 * 1024
|
|
24
37
|
|
|
25
38
|
const DEFAULT_OPTIONS = {
|
|
26
39
|
maxTurns: 10,
|
|
@@ -45,24 +58,70 @@ const DEFAULT_OPTIONS = {
|
|
|
45
58
|
// is the full registry of live goals per session (focused + backgrounded);
|
|
46
59
|
// the focused goal is the same object reference held in both. `sessionArchive`
|
|
47
60
|
// keeps a capped list of completed/cleared goals so they stay readable.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
function createRuntimeState() {
|
|
62
|
+
return {
|
|
63
|
+
goalStates: new Map(),
|
|
64
|
+
sessionGoals: new Map(),
|
|
65
|
+
sessionArchive: new Map(),
|
|
66
|
+
sessionOrdered: new Set(),
|
|
67
|
+
lastGoalResults: new Map(),
|
|
68
|
+
seenTokens: new Map(),
|
|
69
|
+
seenUsage: new Map(),
|
|
70
|
+
seenOutputTokens: new Map(),
|
|
71
|
+
activeContinues: new Map(),
|
|
72
|
+
continuationControllers: new Map(),
|
|
73
|
+
seenIdleEventIDs: new Set(),
|
|
74
|
+
ledgerSink: null,
|
|
75
|
+
persistenceLease: null,
|
|
76
|
+
disposed: false,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const runtimeStorage = new AsyncLocalStorage()
|
|
81
|
+
let lastRuntime = createRuntimeState()
|
|
82
|
+
|
|
83
|
+
function currentRuntime() {
|
|
84
|
+
return runtimeStorage.getStore() || lastRuntime
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Route the existing domain helpers to the plugin instance associated with the
|
|
88
|
+
// current async hook/tool execution. OpenCode caches imported plugin modules but
|
|
89
|
+
// initializes their factories per workspace, so module-global Maps would let a
|
|
90
|
+
// second workspace clear or persist the first workspace's goals. The proxies
|
|
91
|
+
// keep the mature helper surface intact while making every collection
|
|
92
|
+
// instance-scoped.
|
|
93
|
+
function runtimeCollection(name) {
|
|
94
|
+
return new Proxy(
|
|
95
|
+
{},
|
|
96
|
+
{
|
|
97
|
+
get(_target, property) {
|
|
98
|
+
const collection = currentRuntime()[name]
|
|
99
|
+
const value = collection[property]
|
|
100
|
+
return typeof value === "function" ? value.bind(collection) : value
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const goalStates = runtimeCollection("goalStates")
|
|
107
|
+
const sessionGoals = runtimeCollection("sessionGoals")
|
|
108
|
+
const sessionArchive = runtimeCollection("sessionArchive")
|
|
51
109
|
// Sessions running an ordered (sisyphus) sequence: when the focused goal
|
|
52
110
|
// completes, the next live goal (in creation order) is auto-promoted to focus
|
|
53
111
|
// so the sequence advances on its own.
|
|
54
|
-
const sessionOrdered =
|
|
112
|
+
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
55
113
|
const MAX_ARCHIVED_PER_SESSION = 10
|
|
56
|
-
const lastGoalResults =
|
|
57
|
-
const seenTokens =
|
|
58
|
-
const
|
|
114
|
+
const lastGoalResults = runtimeCollection("lastGoalResults")
|
|
115
|
+
const seenTokens = runtimeCollection("seenTokens")
|
|
116
|
+
const seenUsage = runtimeCollection("seenUsage")
|
|
117
|
+
const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
59
118
|
// Map<sessionID, token> rather than Set so the idle handler's finally block can
|
|
60
119
|
// detect whether its entry has been superseded by a new handler: if cleanupGoal
|
|
61
120
|
// deletes the sessionID (allowing a new handler to start and set a fresh token)
|
|
62
121
|
// before the old handler's finally fires, the old finally skips the delete
|
|
63
122
|
// because the token no longer matches. With a plain Set, the old finally would
|
|
64
123
|
// unconditionally delete the new handler's guard, exposing a race window.
|
|
65
|
-
const activeContinues =
|
|
124
|
+
const activeContinues = runtimeCollection("activeContinues")
|
|
66
125
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
67
126
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
68
127
|
const GOAL_FLAG_SPECS = {
|
|
@@ -148,8 +207,17 @@ function getText(parts) {
|
|
|
148
207
|
.trim()
|
|
149
208
|
}
|
|
150
209
|
|
|
151
|
-
function makeTextPart(text) {
|
|
152
|
-
return { type: "text", text }
|
|
210
|
+
function makeTextPart(text, extra = {}) {
|
|
211
|
+
return { type: "text", text, ...extra }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function makeContinuationPart(text) {
|
|
215
|
+
return makeTextPart(text, {
|
|
216
|
+
synthetic: true,
|
|
217
|
+
metadata: {
|
|
218
|
+
"opencode-goal-plugin": { kind: "continuation" },
|
|
219
|
+
},
|
|
220
|
+
})
|
|
153
221
|
}
|
|
154
222
|
|
|
155
223
|
function getSessionID(event) {
|
|
@@ -163,6 +231,14 @@ function isIdleEvent(event) {
|
|
|
163
231
|
)
|
|
164
232
|
}
|
|
165
233
|
|
|
234
|
+
function isAbortErrorEvent(event) {
|
|
235
|
+
if (event?.type !== "session.error") return false
|
|
236
|
+
const error = event?.properties?.error
|
|
237
|
+
const name = String(error?.name || error?.data?.name || "")
|
|
238
|
+
const message = String(error?.message || error?.data?.message || "")
|
|
239
|
+
return name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
166
242
|
function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
|
|
167
243
|
const normalized = String(text || "").replace(/\s+/g, " ").trim()
|
|
168
244
|
if (!normalized) return ""
|
|
@@ -193,13 +269,12 @@ function makeHistoryEntry(type, detail, timestamp = Date.now()) {
|
|
|
193
269
|
// ledger is the durable record used to reconstruct state if the main state file
|
|
194
270
|
// is lost or corrupted, and it captures terminal events even when the main
|
|
195
271
|
// state write fails (fail-closed, item 2.5).
|
|
196
|
-
let ledgerSink = null
|
|
197
|
-
|
|
198
272
|
function setLedgerSink(sink) {
|
|
199
|
-
ledgerSink = typeof sink === "function" ? sink : null
|
|
273
|
+
currentRuntime().ledgerSink = typeof sink === "function" ? sink : null
|
|
200
274
|
}
|
|
201
275
|
|
|
202
276
|
function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
277
|
+
const ledgerSink = currentRuntime().ledgerSink
|
|
203
278
|
if (!ledgerSink) return
|
|
204
279
|
try {
|
|
205
280
|
ledgerSink({
|
|
@@ -224,32 +299,89 @@ function pushHistory(goal, type, detail, timestamp = Date.now()) {
|
|
|
224
299
|
// Synchronous append keeps lifecycle events ordered and durable without
|
|
225
300
|
// unawaited promises leaking past teardown. Owner-only perms mirror the state
|
|
226
301
|
// file. Failures are reported to the caller, not thrown.
|
|
227
|
-
function
|
|
302
|
+
function rotateLedger(ledgerFilePath, retentionFiles) {
|
|
303
|
+
if (retentionFiles <= 0) {
|
|
304
|
+
rmSync(ledgerFilePath, { force: true })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
rmSync(`${ledgerFilePath}.${retentionFiles}`, { force: true })
|
|
308
|
+
for (let index = retentionFiles - 1; index >= 1; index -= 1) {
|
|
309
|
+
try {
|
|
310
|
+
renameSync(`${ledgerFilePath}.${index}`, `${ledgerFilePath}.${index + 1}`)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error?.code !== "ENOENT") throw error
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
renameSync(ledgerFilePath, `${ledgerFilePath}.1`)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error?.code !== "ENOENT") throw error
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function appendLedgerLine(
|
|
323
|
+
ledgerFilePath,
|
|
324
|
+
entry,
|
|
325
|
+
{ maxBytes = DEFAULT_LEDGER_MAX_BYTES, retentionFiles = DEFAULT_LEDGER_RETENTION_FILES } = {},
|
|
326
|
+
) {
|
|
228
327
|
try {
|
|
229
328
|
mkdirSync(dirname(ledgerFilePath), { recursive: true, mode: 0o700 })
|
|
230
|
-
|
|
329
|
+
const line = `${JSON.stringify(entry)}\n`
|
|
330
|
+
if (Buffer.byteLength(line) > MAX_LEDGER_LINE_BYTES) return false
|
|
331
|
+
let currentBytes = 0
|
|
332
|
+
try {
|
|
333
|
+
currentBytes = statSync(ledgerFilePath).size
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (error?.code !== "ENOENT") throw error
|
|
336
|
+
}
|
|
337
|
+
if (currentBytes + Buffer.byteLength(line) > maxBytes) {
|
|
338
|
+
rotateLedger(ledgerFilePath, retentionFiles)
|
|
339
|
+
}
|
|
340
|
+
appendFileSync(ledgerFilePath, line, { mode: 0o600 })
|
|
341
|
+
chmodSync(ledgerFilePath, 0o600)
|
|
231
342
|
return true
|
|
232
343
|
} catch {
|
|
233
344
|
return false
|
|
234
345
|
}
|
|
235
346
|
}
|
|
236
347
|
|
|
237
|
-
async function readLedgerEntries(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
} catch {
|
|
242
|
-
return []
|
|
243
|
-
}
|
|
348
|
+
async function readLedgerEntries(
|
|
349
|
+
ledgerFilePath,
|
|
350
|
+
{ maxBytes = DEFAULT_LEDGER_MAX_BYTES, retentionFiles = DEFAULT_LEDGER_RETENTION_FILES } = {},
|
|
351
|
+
) {
|
|
244
352
|
const entries = []
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
353
|
+
const paths = [
|
|
354
|
+
...Array.from({ length: retentionFiles }, (_, index) => `${ledgerFilePath}.${retentionFiles - index}`),
|
|
355
|
+
ledgerFilePath,
|
|
356
|
+
]
|
|
357
|
+
for (const path of paths) {
|
|
358
|
+
let raw
|
|
248
359
|
try {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
360
|
+
const handle = await fs.open(path, "r")
|
|
361
|
+
try {
|
|
362
|
+
const { size } = await handle.stat()
|
|
363
|
+
const length = Math.min(size, maxBytes)
|
|
364
|
+
const buffer = Buffer.alloc(length)
|
|
365
|
+
await handle.read(buffer, 0, length, size - length)
|
|
366
|
+
raw = buffer.toString("utf8")
|
|
367
|
+
if (size > length) raw = raw.slice(raw.indexOf("\n") + 1)
|
|
368
|
+
} finally {
|
|
369
|
+
await handle.close()
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error?.code === "ENOENT") continue
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
for (const line of raw.split("\n")) {
|
|
376
|
+
if (Buffer.byteLength(line) > MAX_LEDGER_LINE_BYTES) continue
|
|
377
|
+
const trimmed = line.trim()
|
|
378
|
+
if (!trimmed) continue
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(trimmed)
|
|
381
|
+
if (isPlainObject(parsed)) entries.push(parsed)
|
|
382
|
+
} catch {
|
|
383
|
+
// Skip malformed lines so a partial write can't break recovery.
|
|
384
|
+
}
|
|
253
385
|
}
|
|
254
386
|
}
|
|
255
387
|
return entries
|
|
@@ -331,6 +463,7 @@ function formatStatus(goal, commandName = "goal") {
|
|
|
331
463
|
lines.push(
|
|
332
464
|
`Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
|
|
333
465
|
`Context tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`,
|
|
466
|
+
formatUsage(goal.usage),
|
|
334
467
|
`Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
|
|
335
468
|
`Last progress: ${lastProgress}`,
|
|
336
469
|
`No-progress turns: ${goal.noProgressTurns}`,
|
|
@@ -347,6 +480,11 @@ function formatStatus(goal, commandName = "goal") {
|
|
|
347
480
|
return lines.join("\n")
|
|
348
481
|
}
|
|
349
482
|
|
|
483
|
+
function formatUsage(value) {
|
|
484
|
+
const usage = normalizeUsage(value)
|
|
485
|
+
return `API usage: input ${usage.input.toLocaleString()}, output ${usage.output.toLocaleString()}, reasoning ${usage.reasoning.toLocaleString()}, cache read ${usage.cacheRead.toLocaleString()}, cache write ${usage.cacheWrite.toLocaleString()}, cost $${usage.cost.toFixed(4)}`
|
|
486
|
+
}
|
|
487
|
+
|
|
350
488
|
function formatGoalResult(result) {
|
|
351
489
|
const elapsed = Math.round((result.finishedAt - result.startedAt) / 1000)
|
|
352
490
|
const lastCheckpoint = result.lastCheckpoint
|
|
@@ -357,6 +495,7 @@ function formatGoalResult(result) {
|
|
|
357
495
|
`State: ${result.state}`,
|
|
358
496
|
`Auto-continues sent: ${result.turnCount}`,
|
|
359
497
|
`Context tokens: ${result.totalTokens.toLocaleString()}`,
|
|
498
|
+
formatUsage(result.usage),
|
|
360
499
|
`Elapsed: ${elapsed}s`,
|
|
361
500
|
`Last checkpoint: ${lastCheckpoint}`,
|
|
362
501
|
`Last status: ${result.lastStatus || "No status recorded."}`,
|
|
@@ -464,14 +603,19 @@ function cleanupGoal(sessionID) {
|
|
|
464
603
|
}
|
|
465
604
|
|
|
466
605
|
function clearRuntimeState() {
|
|
606
|
+
const runtime = currentRuntime()
|
|
607
|
+
for (const controller of runtime.continuationControllers.values()) controller.abort()
|
|
467
608
|
goalStates.clear()
|
|
468
609
|
sessionGoals.clear()
|
|
469
610
|
sessionArchive.clear()
|
|
470
611
|
sessionOrdered.clear()
|
|
471
612
|
lastGoalResults.clear()
|
|
472
613
|
seenTokens.clear()
|
|
614
|
+
seenUsage.clear()
|
|
473
615
|
seenOutputTokens.clear()
|
|
474
616
|
activeContinues.clear()
|
|
617
|
+
runtime.continuationControllers.clear()
|
|
618
|
+
runtime.seenIdleEventIDs.clear()
|
|
475
619
|
}
|
|
476
620
|
|
|
477
621
|
function pruneGoalResults(options) {
|
|
@@ -501,6 +645,7 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
|
|
|
501
645
|
blockedReason: goal.blockedReason,
|
|
502
646
|
turnCount: goal.turnCount,
|
|
503
647
|
totalTokens: goal.totalTokens,
|
|
648
|
+
usage: normalizeUsage(goal.usage),
|
|
504
649
|
startedAt: goal.startedAt,
|
|
505
650
|
finishedAt: Date.now(),
|
|
506
651
|
lastStatus: goal.lastStatus,
|
|
@@ -521,10 +666,13 @@ function resetGoalBudget(goal) {
|
|
|
521
666
|
// is in seenTokens but NOT in the current goal.messageIDs — keeping the entries
|
|
522
667
|
// alive is what makes that check reliable. cleanupGoal removes them when the
|
|
523
668
|
// goal is fully discarded, so seenTokens entries are bounded to active goals.
|
|
524
|
-
|
|
669
|
+
// Keep the registry identity stable. runId is the execution epoch used to
|
|
670
|
+
// reject stale handlers from the previous budget window.
|
|
671
|
+
goal.runId = randomUUID()
|
|
525
672
|
goal.startedAt = Date.now()
|
|
526
673
|
goal.turnCount = 0
|
|
527
674
|
goal.totalTokens = 0
|
|
675
|
+
goal.usage = emptyUsage()
|
|
528
676
|
goal.lastContinueAt = 0
|
|
529
677
|
goal.lastProgressAt = 0
|
|
530
678
|
goal.noProgressTurns = 0
|
|
@@ -537,10 +685,11 @@ function resetGoalBudget(goal) {
|
|
|
537
685
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
538
686
|
}
|
|
539
687
|
|
|
540
|
-
function currentGoal(sessionID, goalID) {
|
|
688
|
+
function currentGoal(sessionID, goalID, runID) {
|
|
541
689
|
const goal = goalStates.get(sessionID)
|
|
542
690
|
if (!goal) return null
|
|
543
691
|
if (goalID !== undefined && goal.goalId !== goalID) return null
|
|
692
|
+
if (runID !== undefined && goal.runId !== runID) return null
|
|
544
693
|
return goal
|
|
545
694
|
}
|
|
546
695
|
|
|
@@ -548,8 +697,8 @@ function currentGoal(sessionID, goalID) {
|
|
|
548
697
|
// cleared-and-replaced, blocked) while an async step was in flight. Used at the
|
|
549
698
|
// post-await re-checks so a `/goal pause` issued during messages-fetch or the
|
|
550
699
|
// cooldown sleep actually prevents the next auto-continue from firing.
|
|
551
|
-
function activeGoal(sessionID, goalID) {
|
|
552
|
-
const goal = currentGoal(sessionID, goalID)
|
|
700
|
+
function activeGoal(sessionID, goalID, runID) {
|
|
701
|
+
const goal = currentGoal(sessionID, goalID, runID)
|
|
553
702
|
if (!goal || goal.stopped) return null
|
|
554
703
|
return goal
|
|
555
704
|
}
|
|
@@ -686,7 +835,11 @@ function normalizePersistenceOptions(options = {}, { env = process.env, cwd } =
|
|
|
686
835
|
typeof options.ledgerFilePath === "string" && options.ledgerFilePath.trim()
|
|
687
836
|
? options.ledgerFilePath.trim()
|
|
688
837
|
: ledgerPathFor(stateFilePath)
|
|
689
|
-
|
|
838
|
+
const ledgerMaxBytes = toPositiveInteger(options.ledgerMaxBytes, DEFAULT_LEDGER_MAX_BYTES)
|
|
839
|
+
const ledgerRetentionFiles = Number.isSafeInteger(options.ledgerRetentionFiles) && options.ledgerRetentionFiles >= 0
|
|
840
|
+
? Math.min(options.ledgerRetentionFiles, 10)
|
|
841
|
+
: DEFAULT_LEDGER_RETENTION_FILES
|
|
842
|
+
return { persistState, stateFilePath, fallbackPaths, ledgerFilePath, ledgerMaxBytes, ledgerRetentionFiles }
|
|
690
843
|
}
|
|
691
844
|
|
|
692
845
|
// Command surface options (item 8.2): `commandName` lets the plugin own a
|
|
@@ -754,6 +907,10 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
754
907
|
typeof rawGoal.goalId === "string" && rawGoal.goalId.trim()
|
|
755
908
|
? rawGoal.goalId
|
|
756
909
|
: randomUUID(),
|
|
910
|
+
runId:
|
|
911
|
+
typeof rawGoal.runId === "string" && rawGoal.runId.trim()
|
|
912
|
+
? rawGoal.runId
|
|
913
|
+
: randomUUID(),
|
|
757
914
|
condition: rawGoal.condition.trim(),
|
|
758
915
|
successCriteria: typeof rawGoal.successCriteria === "string" ? rawGoal.successCriteria : "",
|
|
759
916
|
constraints: typeof rawGoal.constraints === "string" ? rawGoal.constraints : "",
|
|
@@ -762,6 +919,7 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
762
919
|
turnCount: toNonNegativeInteger(rawGoal.turnCount),
|
|
763
920
|
startedAt: normalizeTimestamp(rawGoal.startedAt),
|
|
764
921
|
totalTokens: toNonNegativeInteger(rawGoal.totalTokens),
|
|
922
|
+
usage: normalizeUsage(rawGoal.usage),
|
|
765
923
|
options: normalizeOptions(isPlainObject(rawGoal.options) ? rawGoal.options : {}),
|
|
766
924
|
lastStatus: typeof rawGoal.lastStatus === "string" ? rawGoal.lastStatus : "Goal recovered.",
|
|
767
925
|
lastAssistantText:
|
|
@@ -804,6 +962,7 @@ function normalizePersistedResult(rawResult) {
|
|
|
804
962
|
blockedReason: typeof rawResult.blockedReason === "string" ? rawResult.blockedReason : "",
|
|
805
963
|
turnCount: toNonNegativeInteger(rawResult.turnCount),
|
|
806
964
|
totalTokens: toNonNegativeInteger(rawResult.totalTokens),
|
|
965
|
+
usage: normalizeUsage(rawResult.usage),
|
|
807
966
|
startedAt: normalizeTimestamp(rawResult.startedAt),
|
|
808
967
|
finishedAt: normalizeTimestamp(rawResult.finishedAt),
|
|
809
968
|
lastStatus: typeof rawResult.lastStatus === "string" ? rawResult.lastStatus : "",
|
|
@@ -943,7 +1102,10 @@ async function applyParsedStateFile(raw, client) {
|
|
|
943
1102
|
// but still appears active in the state file (because the state write failed
|
|
944
1103
|
// after the terminal ledger write), remove it so it is not re-driven.
|
|
945
1104
|
async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
946
|
-
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath
|
|
1105
|
+
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1106
|
+
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1107
|
+
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1108
|
+
})
|
|
947
1109
|
if (!entries.length) return
|
|
948
1110
|
|
|
949
1111
|
const terminalGoalIds = new Set()
|
|
@@ -1023,7 +1185,10 @@ async function loadPersistedState(persistenceOptions, client) {
|
|
|
1023
1185
|
// goals from the append-only ledger so a lost/rotated state file does not drop
|
|
1024
1186
|
// in-flight goals (item 2.3). Recovered goals are paused (via deserializeGoal).
|
|
1025
1187
|
async function reconstructFromLedger(persistenceOptions, client) {
|
|
1026
|
-
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath
|
|
1188
|
+
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1189
|
+
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1190
|
+
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1191
|
+
})
|
|
1027
1192
|
if (!entries.length) return "missing"
|
|
1028
1193
|
|
|
1029
1194
|
const reconstructed = reconstructGoalsFromLedger(entries)
|
|
@@ -1189,16 +1354,37 @@ function parseGoalArguments(args, defaults) {
|
|
|
1189
1354
|
condition.push(stripWrappingQuotes(part))
|
|
1190
1355
|
}
|
|
1191
1356
|
|
|
1357
|
+
const parsedCondition = condition.join(" ").trim()
|
|
1358
|
+
if (parsedCondition.length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
1359
|
+
errors.push(`Goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer`)
|
|
1360
|
+
}
|
|
1361
|
+
for (const [field, value] of [["success criteria", meta.successCriteria], ["constraints", meta.constraints]]) {
|
|
1362
|
+
if (value.length > MAX_GOAL_META_LENGTH) {
|
|
1363
|
+
errors.push(`${field} must be ${MAX_GOAL_META_LENGTH} characters or fewer`)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1192
1366
|
return {
|
|
1193
|
-
condition:
|
|
1367
|
+
condition: parsedCondition,
|
|
1194
1368
|
options,
|
|
1195
1369
|
meta,
|
|
1196
1370
|
errors,
|
|
1197
1371
|
}
|
|
1198
1372
|
}
|
|
1199
1373
|
|
|
1200
|
-
function sleep(ms) {
|
|
1201
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
1374
|
+
function sleep(ms, signal) {
|
|
1375
|
+
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms))
|
|
1376
|
+
if (signal.aborted) return Promise.resolve(false)
|
|
1377
|
+
return new Promise((resolve) => {
|
|
1378
|
+
const timer = setTimeout(() => {
|
|
1379
|
+
signal.removeEventListener("abort", onAbort)
|
|
1380
|
+
resolve(true)
|
|
1381
|
+
}, ms)
|
|
1382
|
+
const onAbort = () => {
|
|
1383
|
+
clearTimeout(timer)
|
|
1384
|
+
resolve(false)
|
|
1385
|
+
}
|
|
1386
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
1387
|
+
})
|
|
1202
1388
|
}
|
|
1203
1389
|
|
|
1204
1390
|
function buildLimitWarning(goal) {
|
|
@@ -1260,7 +1446,7 @@ function escapeGoalText(text) {
|
|
|
1260
1446
|
|
|
1261
1447
|
function buildGoalBlock(goal) {
|
|
1262
1448
|
const lines = [
|
|
1263
|
-
"
|
|
1449
|
+
"User goal (user-provided task data):",
|
|
1264
1450
|
"<goal_objective>",
|
|
1265
1451
|
escapeGoalText(goal.condition),
|
|
1266
1452
|
"</goal_objective>",
|
|
@@ -1268,7 +1454,7 @@ function buildGoalBlock(goal) {
|
|
|
1268
1454
|
|
|
1269
1455
|
if (goal.successCriteria) {
|
|
1270
1456
|
lines.push(
|
|
1271
|
-
"Success criteria
|
|
1457
|
+
"Success criteria:",
|
|
1272
1458
|
"<success_criteria>",
|
|
1273
1459
|
escapeGoalText(goal.successCriteria),
|
|
1274
1460
|
"</success_criteria>",
|
|
@@ -1277,7 +1463,7 @@ function buildGoalBlock(goal) {
|
|
|
1277
1463
|
|
|
1278
1464
|
if (goal.constraints) {
|
|
1279
1465
|
lines.push(
|
|
1280
|
-
"Constraints
|
|
1466
|
+
"Constraints:",
|
|
1281
1467
|
"<constraints>",
|
|
1282
1468
|
escapeGoalText(goal.constraints),
|
|
1283
1469
|
"</constraints>",
|
|
@@ -1286,7 +1472,7 @@ function buildGoalBlock(goal) {
|
|
|
1286
1472
|
|
|
1287
1473
|
if (goal.mode === "ordered") {
|
|
1288
1474
|
lines.push(
|
|
1289
|
-
"Mode: ordered
|
|
1475
|
+
"Mode: ordered; finish each step before the next.",
|
|
1290
1476
|
)
|
|
1291
1477
|
}
|
|
1292
1478
|
|
|
@@ -1302,55 +1488,32 @@ function buildContinueMessage(
|
|
|
1302
1488
|
const elapsedSeconds = Math.round((Date.now() - goal.startedAt) / 1000)
|
|
1303
1489
|
const lines = [
|
|
1304
1490
|
"<goal_continuation>",
|
|
1305
|
-
buildGoalBlock(goal),
|
|
1306
|
-
"",
|
|
1307
1491
|
"<progress_budget>",
|
|
1308
|
-
`
|
|
1309
|
-
`
|
|
1310
|
-
`context_tokens_used: ${goal.totalTokens}`,
|
|
1311
|
-
`context_tokens_remaining: ${remainingTokens}`,
|
|
1492
|
+
`turns_remaining: ${remainingTurns}`,
|
|
1493
|
+
`tokens_remaining: ${remainingTokens}`,
|
|
1312
1494
|
`elapsed_seconds: ${elapsedSeconds}`,
|
|
1313
1495
|
"</progress_budget>",
|
|
1314
|
-
"",
|
|
1315
1496
|
]
|
|
1316
1497
|
|
|
1317
1498
|
if (budgetWrapup) {
|
|
1318
1499
|
lines.push(
|
|
1319
1500
|
"<budget_wrapup>",
|
|
1320
|
-
"
|
|
1321
|
-
"Then write a concise handoff summary covering what is done, what remains, and the next concrete command or file to inspect.",
|
|
1322
|
-
"Do not output [goal:complete] unless the goal is actually finished and verified.",
|
|
1323
|
-
"After the handoff, stop.",
|
|
1501
|
+
"Budget limit near. Finish only a small safe step, then summarize done, remaining, and the next action; stop. Do not claim completion unless verified.",
|
|
1324
1502
|
"</budget_wrapup>",
|
|
1325
1503
|
)
|
|
1326
1504
|
} else {
|
|
1327
1505
|
lines.push(
|
|
1328
|
-
"
|
|
1329
|
-
"Continue working toward the active goal. Take the next concrete step.",
|
|
1330
|
-
"Prefer verifying actual current state over assuming prior work succeeded.",
|
|
1331
|
-
"If a check fails, repair the issue rather than shrinking the scope.",
|
|
1332
|
-
"</next_step>",
|
|
1506
|
+
"Continue with the next concrete step; inspect current state and repair failures.",
|
|
1333
1507
|
)
|
|
1334
1508
|
}
|
|
1335
1509
|
|
|
1336
|
-
lines.push(
|
|
1337
|
-
"",
|
|
1338
|
-
"<completion_audit>",
|
|
1339
|
-
"Before outputting [goal:complete], treat completion as unproven.",
|
|
1340
|
-
"Verify the result against the goal objective and the current project state.",
|
|
1341
|
-
"Only mark complete when every requirement is satisfied and any relevant checks have passed or their absence is explicitly justified.",
|
|
1342
|
-
"When you do mark complete, put a line beginning with [goal:evidence] immediately before [goal:complete], summarizing what you verified (commands run and their results, files checked). A [goal:complete] without a [goal:evidence] line is rejected and not recorded.",
|
|
1343
|
-
"If user input is required, explain the specific blocker in the line immediately before [goal:blocked]. A [goal:blocked] without a concrete blocker is rejected.",
|
|
1344
|
-
"</completion_audit>",
|
|
1345
|
-
)
|
|
1510
|
+
lines.push("Complete only after verification: `[goal:evidence] …` then `[goal:complete]`. If only user input can unblock work, state why then `[goal:blocked]`.")
|
|
1346
1511
|
|
|
1347
1512
|
if (completionUnverified) {
|
|
1348
1513
|
lines.push(
|
|
1349
1514
|
"",
|
|
1350
1515
|
"<evidence_required>",
|
|
1351
|
-
"
|
|
1352
|
-
"Do not output [goal:complete] again until the goal is truly finished and verified.",
|
|
1353
|
-
"When it is, put a line starting with [goal:evidence] (summarizing the checks you ran and their results) immediately before [goal:complete].",
|
|
1516
|
+
"Previous completion was rejected: evidence was missing. Verify first, then put `[goal:evidence] …` immediately before `[goal:complete]`.",
|
|
1354
1517
|
"</evidence_required>",
|
|
1355
1518
|
)
|
|
1356
1519
|
}
|
|
@@ -1359,17 +1522,12 @@ function buildContinueMessage(
|
|
|
1359
1522
|
lines.push(
|
|
1360
1523
|
"",
|
|
1361
1524
|
"<evidence_required>",
|
|
1362
|
-
"
|
|
1363
|
-
"If you are truly blocked, state the specific blocker — what you need from the user and why you cannot proceed — on the line immediately before [goal:blocked]. Otherwise keep working.",
|
|
1525
|
+
"Previous blocker was rejected: it was not concrete. State what user input is needed and why, immediately before `[goal:blocked]`; otherwise continue.",
|
|
1364
1526
|
"</evidence_required>",
|
|
1365
1527
|
)
|
|
1366
1528
|
}
|
|
1367
1529
|
|
|
1368
1530
|
lines.push(
|
|
1369
|
-
"",
|
|
1370
|
-
"End with [goal:complete] (preceded by a [goal:evidence] line) only when the goal is fully satisfied.",
|
|
1371
|
-
"End with [goal:blocked] (preceded by a concrete blocker) only if user input is required.",
|
|
1372
|
-
buildLimitWarning(goal),
|
|
1373
1531
|
"</goal_continuation>",
|
|
1374
1532
|
)
|
|
1375
1533
|
|
|
@@ -1418,7 +1576,7 @@ function buildCompactionContext(goal) {
|
|
|
1418
1576
|
buildGoalBlock(goal),
|
|
1419
1577
|
`Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
|
|
1420
1578
|
`Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
|
|
1421
|
-
goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(goal.lastCheckpoint.summary)}` : null,
|
|
1579
|
+
goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(summarizeText(goal.lastCheckpoint.summary, 200))}` : null,
|
|
1422
1580
|
...buildCompactionProgressSummary(goal),
|
|
1423
1581
|
"After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] (preceded by a [goal:evidence] line) only when fully satisfied, or [goal:blocked] (preceded by a concrete blocker) only if user input is required.",
|
|
1424
1582
|
]
|
|
@@ -1500,6 +1658,46 @@ function messageTokens(message) {
|
|
|
1500
1658
|
: {}
|
|
1501
1659
|
}
|
|
1502
1660
|
|
|
1661
|
+
const USAGE_TOKEN_FIELDS = ["input", "output", "reasoning", "cacheRead", "cacheWrite"]
|
|
1662
|
+
|
|
1663
|
+
function emptyUsage() {
|
|
1664
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Normalize both current OpenCode message info and the flattened shapes used by
|
|
1668
|
+
// older SDK adapters. Invalid provider values are ignored so diagnostics can
|
|
1669
|
+
// never corrupt budget enforcement or persisted state.
|
|
1670
|
+
function normalizeMessageUsage(message) {
|
|
1671
|
+
const tokens = messageTokens(message)
|
|
1672
|
+
const cache = isPlainObject(tokens.cache) ? tokens.cache : {}
|
|
1673
|
+
const rawCost = message?.info?.cost ?? message?.cost
|
|
1674
|
+
return {
|
|
1675
|
+
input: toNonNegativeInteger(tokens.input),
|
|
1676
|
+
output: toNonNegativeInteger(tokens.output),
|
|
1677
|
+
reasoning: toNonNegativeInteger(tokens.reasoning),
|
|
1678
|
+
cacheRead: toNonNegativeInteger(cache.read ?? tokens.cacheRead ?? tokens.cache_read),
|
|
1679
|
+
cacheWrite: toNonNegativeInteger(cache.write ?? tokens.cacheWrite ?? tokens.cache_write),
|
|
1680
|
+
cost: Number.isFinite(Number(rawCost)) && Number(rawCost) >= 0 ? Number(rawCost) : 0,
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function normalizeUsage(value) {
|
|
1685
|
+
const source = isPlainObject(value) ? value : {}
|
|
1686
|
+
const usage = emptyUsage()
|
|
1687
|
+
for (const field of USAGE_TOKEN_FIELDS) usage[field] = toNonNegativeInteger(source[field])
|
|
1688
|
+
usage.cost = Number.isFinite(Number(source.cost)) && Number(source.cost) >= 0 ? Number(source.cost) : 0
|
|
1689
|
+
return usage
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function addUsageDelta(total, current, previous) {
|
|
1693
|
+
const next = normalizeUsage(total)
|
|
1694
|
+
for (const field of USAGE_TOKEN_FIELDS) {
|
|
1695
|
+
next[field] += Math.max(0, current[field] - previous[field])
|
|
1696
|
+
}
|
|
1697
|
+
next.cost += Math.max(0, current.cost - previous.cost)
|
|
1698
|
+
return next
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1503
1701
|
function cacheTokensForMessage(tokens) {
|
|
1504
1702
|
// OpenCode reports cached context separately as `cache: { read, write }`.
|
|
1505
1703
|
// On cache-heavy providers (e.g. Anthropic prompt caching) most of the
|
|
@@ -1594,9 +1792,18 @@ function findLatestAssistantMessage(messages) {
|
|
|
1594
1792
|
// any forged <goal_continuation in goal text, so genuine goal text cannot
|
|
1595
1793
|
// masquerade as a plugin continuation.
|
|
1596
1794
|
function isPluginContinuationMessage(message) {
|
|
1597
|
-
return
|
|
1598
|
-
|
|
1795
|
+
if (messageRole(message) !== "user") return false
|
|
1796
|
+
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
1797
|
+
const metadataMarked = parts.some(
|
|
1798
|
+
(part) =>
|
|
1799
|
+
part?.type === "text" &&
|
|
1800
|
+
part.synthetic === true &&
|
|
1801
|
+
part?.metadata?.["opencode-goal-plugin"]?.kind === "continuation",
|
|
1599
1802
|
)
|
|
1803
|
+
if (metadataMarked) return true
|
|
1804
|
+
// Backward compatibility for continuation turns persisted by releases before
|
|
1805
|
+
// synthetic metadata was introduced. New turns must use metadata above.
|
|
1806
|
+
return getText(parts).includes("<goal_continuation>")
|
|
1600
1807
|
}
|
|
1601
1808
|
|
|
1602
1809
|
// "Latest instruction wins": detect a real (human) user message that arrived
|
|
@@ -1635,6 +1842,7 @@ function budgetWrapupNeeded(goal) {
|
|
|
1635
1842
|
function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "Goal set.") {
|
|
1636
1843
|
return {
|
|
1637
1844
|
goalId: randomUUID(),
|
|
1845
|
+
runId: randomUUID(),
|
|
1638
1846
|
condition,
|
|
1639
1847
|
successCriteria: typeof meta.successCriteria === "string" ? meta.successCriteria : "",
|
|
1640
1848
|
constraints: typeof meta.constraints === "string" ? meta.constraints : "",
|
|
@@ -1643,6 +1851,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
1643
1851
|
turnCount: 0,
|
|
1644
1852
|
startedAt: Date.now(),
|
|
1645
1853
|
totalTokens: 0,
|
|
1854
|
+
usage: emptyUsage(),
|
|
1646
1855
|
options,
|
|
1647
1856
|
lastStatus,
|
|
1648
1857
|
lastAssistantText: "",
|
|
@@ -1713,6 +1922,12 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
1713
1922
|
async function setGoal(sessionID, args = {}) {
|
|
1714
1923
|
const objective = typeof args.objective === "string" ? args.objective.trim() : ""
|
|
1715
1924
|
if (!objective) return "No objective provided. Pass a non-empty `objective`."
|
|
1925
|
+
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH)
|
|
1926
|
+
return `Invalid objective: must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`
|
|
1927
|
+
for (const [field, value] of [["successCriteria", args.successCriteria], ["constraints", args.constraints]]) {
|
|
1928
|
+
if (typeof value === "string" && value.length > MAX_GOAL_META_LENGTH)
|
|
1929
|
+
return `Invalid ${field}: must be ${MAX_GOAL_META_LENGTH} characters or fewer.`
|
|
1930
|
+
}
|
|
1716
1931
|
|
|
1717
1932
|
// Validate budget args before normalizing: normalizeOptions silently substitutes
|
|
1718
1933
|
// defaults for non-positive values, giving no feedback to the caller.
|
|
@@ -1780,6 +1995,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
1780
1995
|
const messages = []
|
|
1781
1996
|
|
|
1782
1997
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
1998
|
+
if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
1999
|
+
return `Invalid objective: must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`
|
|
2000
|
+
}
|
|
1783
2001
|
goal.condition = args.objective.trim()
|
|
1784
2002
|
// Deliberately NOT clearing goal.stopped or goal.stopReason: updating the
|
|
1785
2003
|
// objective does not un-stop a goal. Use status='resumed' to explicitly
|
|
@@ -1802,6 +2020,8 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
1802
2020
|
}
|
|
1803
2021
|
if (status === "complete") {
|
|
1804
2022
|
const evidence = typeof args.evidence === "string" ? args.evidence.trim() : ""
|
|
2023
|
+
if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
|
|
2024
|
+
return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
|
|
1805
2025
|
// If a completion auditor is configured, run it before archiving so the
|
|
1806
2026
|
// agent tool path has the same integrity gate as the [goal:complete] marker
|
|
1807
2027
|
// path. Without this, an autonomous agent could bypass the auditor by
|
|
@@ -1840,6 +2060,8 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
1840
2060
|
const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
1841
2061
|
if (!blockerText)
|
|
1842
2062
|
return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
|
|
2063
|
+
if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
|
|
2064
|
+
return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
|
|
1843
2065
|
goal.blockedReason = blockerText
|
|
1844
2066
|
goal.stopped = true
|
|
1845
2067
|
goal.stopReason = "blocked"
|
|
@@ -1921,7 +2143,101 @@ function buildAgentTools(toolHelper, handlers) {
|
|
|
1921
2143
|
if (!sessionID) return "No session id available for the goal tool."
|
|
1922
2144
|
return handler(sessionID, args || {})
|
|
1923
2145
|
}
|
|
2146
|
+
// Canonical tools use a small, versioned machine-readable envelope. Keep the
|
|
2147
|
+
// legacy tools below byte-for-byte compatible: existing agents may parse
|
|
2148
|
+
// their human-readable results.
|
|
2149
|
+
const canonicalRun = (operation, handler) => async (args, ctx) => {
|
|
2150
|
+
const sessionID = agentToolSessionID(ctx)
|
|
2151
|
+
if (!sessionID) {
|
|
2152
|
+
return serializeGoalToolResult(
|
|
2153
|
+
operation,
|
|
2154
|
+
goalToolFailure("missing_session", "No session id available for the goal tool."),
|
|
2155
|
+
)
|
|
2156
|
+
}
|
|
2157
|
+
return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const canonicalHandlers = {
|
|
2161
|
+
status: async (sessionID) => goalToolSuccess(await handlers.getGoal(sessionID)),
|
|
2162
|
+
set: async (sessionID, args) => {
|
|
2163
|
+
if (typeof args.objective !== "string" || !args.objective.trim()) {
|
|
2164
|
+
return goalToolFailure("invalid_objective", "No objective provided. Pass a non-empty objective.")
|
|
2165
|
+
}
|
|
2166
|
+
return goalToolSuccess(await handlers.setGoal(sessionID, args))
|
|
2167
|
+
},
|
|
2168
|
+
update: async (sessionID, args) => {
|
|
2169
|
+
const before = currentGoal(sessionID)
|
|
2170
|
+
if (!before) return goalToolFailure("no_active_goal", "No active goal for this session.")
|
|
2171
|
+
if (args.status === "blocked" && (typeof args.blocker !== "string" || !args.blocker.trim())) {
|
|
2172
|
+
return goalToolFailure("missing_blocker", "A non-empty blocker is required.")
|
|
2173
|
+
}
|
|
2174
|
+
if (args.status === "resumed" && !before.stopped) {
|
|
2175
|
+
return goalToolFailure("already_running", "Goal is already running.")
|
|
2176
|
+
}
|
|
2177
|
+
const message = await handlers.updateGoal(sessionID, args)
|
|
2178
|
+
if (args.status === "complete" && currentGoal(sessionID)) {
|
|
2179
|
+
return goalToolFailure("completion_rejected", message)
|
|
2180
|
+
}
|
|
2181
|
+
return goalToolSuccess(message)
|
|
2182
|
+
},
|
|
2183
|
+
}
|
|
1924
2184
|
return {
|
|
2185
|
+
goal_status: toolHelper({
|
|
2186
|
+
description: "Return the current goal state in a compact, versioned JSON envelope.",
|
|
2187
|
+
args: {},
|
|
2188
|
+
execute: canonicalRun("status", canonicalHandlers.status),
|
|
2189
|
+
}),
|
|
2190
|
+
goal_set: toolHelper({
|
|
2191
|
+
description:
|
|
2192
|
+
"Set or replace the session goal. Call only when the user explicitly asks to set or pursue a goal.",
|
|
2193
|
+
args: {
|
|
2194
|
+
objective: schema.string(),
|
|
2195
|
+
maxTurns: schema.number().optional(),
|
|
2196
|
+
maxTokens: schema.number().optional(),
|
|
2197
|
+
maxDurationMs: schema.number().optional(),
|
|
2198
|
+
successCriteria: schema.string().optional(),
|
|
2199
|
+
constraints: schema.string().optional(),
|
|
2200
|
+
mode: schema.string().optional(),
|
|
2201
|
+
},
|
|
2202
|
+
execute: canonicalRun("set", canonicalHandlers.set),
|
|
2203
|
+
}),
|
|
2204
|
+
goal_pause: toolHelper({
|
|
2205
|
+
description: "Pause the current goal without discarding its state.",
|
|
2206
|
+
args: {},
|
|
2207
|
+
execute: canonicalRun("pause", (sessionID) => canonicalHandlers.update(sessionID, { status: "paused" })),
|
|
2208
|
+
}),
|
|
2209
|
+
goal_resume: toolHelper({
|
|
2210
|
+
description: "Resume a stopped goal with a fresh local budget window.",
|
|
2211
|
+
args: {},
|
|
2212
|
+
execute: canonicalRun("resume", (sessionID) => canonicalHandlers.update(sessionID, { status: "resumed" })),
|
|
2213
|
+
}),
|
|
2214
|
+
goal_block: toolHelper({
|
|
2215
|
+
description: "Stop the current goal as blocked and state the concrete external requirement.",
|
|
2216
|
+
args: { blocker: schema.string() },
|
|
2217
|
+
execute: canonicalRun("block", (sessionID, args) =>
|
|
2218
|
+
canonicalHandlers.update(sessionID, { status: "blocked", blocker: args.blocker }),
|
|
2219
|
+
),
|
|
2220
|
+
}),
|
|
2221
|
+
goal_complete: toolHelper({
|
|
2222
|
+
description: "Submit verified completion evidence and archive the goal only if its completion audit approves.",
|
|
2223
|
+
args: {
|
|
2224
|
+
summary: schema.string(),
|
|
2225
|
+
criteria: schema.array(schema.object({ criterion: schema.string(), evidence: schema.array(schema.string()) })).optional(),
|
|
2226
|
+
checks: schema.array(schema.object({
|
|
2227
|
+
command: schema.string().optional(),
|
|
2228
|
+
result: schema.enum(["passed", "failed", "not-run"]),
|
|
2229
|
+
exitCode: schema.number().optional(),
|
|
2230
|
+
explanation: schema.string().optional(),
|
|
2231
|
+
})).optional(),
|
|
2232
|
+
changedFiles: schema.array(schema.string()).optional(),
|
|
2233
|
+
knownLimitations: schema.array(schema.string()).optional(),
|
|
2234
|
+
},
|
|
2235
|
+
execute: canonicalRun("complete", (sessionID, args) => {
|
|
2236
|
+
const claim = serializeCompletionClaim(args)
|
|
2237
|
+
if (!claim.ok) return goalToolFailure("invalid_completion_claim", `Invalid completion claim: ${claim.error}.`)
|
|
2238
|
+
return canonicalHandlers.update(sessionID, { status: "complete", evidence: claim.evidence })
|
|
2239
|
+
}),
|
|
2240
|
+
}),
|
|
1925
2241
|
get_goal: toolHelper({
|
|
1926
2242
|
description:
|
|
1927
2243
|
"Get the status of the current goal for this session (objective, budget usage, last checkpoint).",
|
|
@@ -2061,31 +2377,38 @@ function extractAuditVerdictText(response) {
|
|
|
2061
2377
|
}
|
|
2062
2378
|
|
|
2063
2379
|
// Best-effort built-in auditor: spawns an OpenCode child session to verify the
|
|
2064
|
-
// completion.
|
|
2065
|
-
//
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2380
|
+
// completion. Operational failures reject by default; callers can explicitly
|
|
2381
|
+
// opt into the legacy fail-open policy for compatibility.
|
|
2382
|
+
function createChildSessionAuditor(
|
|
2383
|
+
client,
|
|
2384
|
+
{ agent = "build", timeoutMs = 120_000, sdkShape = "legacy", failurePolicy = "reject" } = {},
|
|
2385
|
+
) {
|
|
2386
|
+
if (failurePolicy !== "reject" && failurePolicy !== "approve") {
|
|
2387
|
+
throw new TypeError('auditorOptions.failurePolicy must be "reject" or "approve"')
|
|
2388
|
+
}
|
|
2389
|
+
const operationalFailure = (reason) => ({
|
|
2390
|
+
approved: failurePolicy === "approve",
|
|
2391
|
+
reason: `${reason}; ${failurePolicy === "approve" ? "auto-approved by configured failure policy" : "rejected by default failure policy"}`,
|
|
2392
|
+
})
|
|
2069
2393
|
return async ({ goal, sessionID, latestText }) => {
|
|
2394
|
+
let childID
|
|
2070
2395
|
const run = async () => {
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
return { approved: true, reason: "child-session API unavailable; auto-approved" }
|
|
2396
|
+
if (!client?.session?.create || !client?.session?.prompt) {
|
|
2397
|
+
return operationalFailure("child-session API unavailable")
|
|
2074
2398
|
}
|
|
2075
|
-
const
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
body: { parts: [makeTextPart(buildAuditPrompt(goal, latestText))], agent },
|
|
2399
|
+
const sessionApi = createOpenCodeSessionApi(client, { preferredShape: sdkShape })
|
|
2400
|
+
const created = await sessionApi.createChild(sessionID, { title: "goal completion audit" })
|
|
2401
|
+
childID = created?.id || created?.sessionID
|
|
2402
|
+
if (!childID) return operationalFailure("child session id unavailable")
|
|
2403
|
+
|
|
2404
|
+
const response = await sessionApi.prompt(childID, {
|
|
2405
|
+
parts: [makeTextPart(buildAuditPrompt(goal, latestText))],
|
|
2406
|
+
agent,
|
|
2084
2407
|
})
|
|
2085
2408
|
let verdictText = extractAuditVerdictText(response)
|
|
2086
|
-
if (!verdictText &&
|
|
2087
|
-
const messages = await sessionApi.messages(
|
|
2088
|
-
verdictText = getText(findLatestAssistantMessage(messages
|
|
2409
|
+
if (!verdictText && client.session.messages) {
|
|
2410
|
+
const messages = await sessionApi.messages(childID, { limit: 10 })
|
|
2411
|
+
verdictText = getText(findLatestAssistantMessage(messages)?.parts)
|
|
2089
2412
|
}
|
|
2090
2413
|
return parseAuditVerdict(verdictText)
|
|
2091
2414
|
}
|
|
@@ -2093,7 +2416,16 @@ function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_00
|
|
|
2093
2416
|
let timerID
|
|
2094
2417
|
const timeout = new Promise((resolve) => {
|
|
2095
2418
|
timerID = setTimeout(
|
|
2096
|
-
() =>
|
|
2419
|
+
async () => {
|
|
2420
|
+
if (childID && typeof client?.session?.abort === "function") {
|
|
2421
|
+
try {
|
|
2422
|
+
await createOpenCodeSessionApi(client, { preferredShape: sdkShape }).abort(childID)
|
|
2423
|
+
} catch {
|
|
2424
|
+
// The timeout verdict remains fail-closed even if host cancellation fails.
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
resolve(operationalFailure(`auditor timed out after ${timeoutMs}ms`))
|
|
2428
|
+
},
|
|
2097
2429
|
timeoutMs,
|
|
2098
2430
|
)
|
|
2099
2431
|
})
|
|
@@ -2102,19 +2434,40 @@ function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_00
|
|
|
2102
2434
|
const result = await Promise.race([run(), timeout])
|
|
2103
2435
|
return result
|
|
2104
2436
|
} catch (error) {
|
|
2105
|
-
return
|
|
2437
|
+
return operationalFailure(`auditor error: ${error?.message || error}`)
|
|
2106
2438
|
} finally {
|
|
2107
2439
|
clearTimeout(timerID)
|
|
2108
2440
|
}
|
|
2109
2441
|
}
|
|
2110
2442
|
}
|
|
2111
2443
|
|
|
2112
|
-
|
|
2444
|
+
async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) {
|
|
2445
|
+
if (pluginOptions.completionAudit && pluginOptions.registerAgents === false) {
|
|
2446
|
+
throw new TypeError("completionAudit requires registerAgents to remain enabled")
|
|
2447
|
+
}
|
|
2448
|
+
// PluginInput currently supplies the legacy generated SDK client, while
|
|
2449
|
+
// consumers embedding the plugin may provide the flattened v2 client. Keep
|
|
2450
|
+
// the host-native legacy shape as the default and allow explicit flat mode;
|
|
2451
|
+
// the adapter safely probes only on argument-validation TypeErrors.
|
|
2452
|
+
const sessionApi = createOpenCodeSessionApi(client, {
|
|
2453
|
+
preferredShape: pluginOptions.sdkShape === "flat" ? "flat" : "legacy",
|
|
2454
|
+
})
|
|
2113
2455
|
const defaultGoalOptions = normalizeOptions(pluginOptions)
|
|
2456
|
+
// OpenCode's PluginInput carries the active session's project directory
|
|
2457
|
+
// separately from the Node process's own process.cwd(), which — when
|
|
2458
|
+
// OpenCode runs as a persistent server/daemon serving multiple
|
|
2459
|
+
// projects/sessions — does NOT track the session's directory. Falling back
|
|
2460
|
+
// to process.cwd() here would silently resolve the project-local state
|
|
2461
|
+
// path against wherever the server happened to boot, not the project the
|
|
2462
|
+
// user is actually working in. An explicit `cwd` plugin option (mainly for
|
|
2463
|
+
// tests) still takes precedence.
|
|
2114
2464
|
const persistenceOptions = normalizePersistenceOptions(pluginOptions, {
|
|
2115
2465
|
env: pluginOptions.env,
|
|
2116
|
-
cwd: pluginOptions.cwd,
|
|
2466
|
+
cwd: pluginOptions.cwd || directory,
|
|
2117
2467
|
})
|
|
2468
|
+
if (persistenceOptions.persistState) {
|
|
2469
|
+
currentRuntime().persistenceLease = await acquirePersistenceLease(persistenceOptions.stateFilePath)
|
|
2470
|
+
}
|
|
2118
2471
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
2119
2472
|
// Serialize all persist() calls through a promise chain so concurrent callers
|
|
2120
2473
|
// never race on the temp-file rename. persistState returns a boolean and never
|
|
@@ -2142,7 +2495,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2142
2495
|
|
|
2143
2496
|
// Route lifecycle events to the JSONL ledger only when persistence is on.
|
|
2144
2497
|
if (persistenceOptions.persistState) {
|
|
2145
|
-
setLedgerSink((entry) => appendLedgerLine(persistenceOptions.ledgerFilePath, entry
|
|
2498
|
+
setLedgerSink((entry) => appendLedgerLine(persistenceOptions.ledgerFilePath, entry, {
|
|
2499
|
+
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
2500
|
+
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
2501
|
+
}))
|
|
2146
2502
|
} else {
|
|
2147
2503
|
setLedgerSink(null)
|
|
2148
2504
|
}
|
|
@@ -2168,7 +2524,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2168
2524
|
typeof pluginOptions.auditor === "function"
|
|
2169
2525
|
? pluginOptions.auditor
|
|
2170
2526
|
: pluginOptions.completionAudit
|
|
2171
|
-
? createChildSessionAuditor(client,
|
|
2527
|
+
? createChildSessionAuditor(client, {
|
|
2528
|
+
...(pluginOptions.auditorOptions || {}),
|
|
2529
|
+
agent: pluginOptions.verifierAgentName || "goal-verify",
|
|
2530
|
+
})
|
|
2172
2531
|
: null
|
|
2173
2532
|
|
|
2174
2533
|
clearRuntimeState()
|
|
@@ -2188,6 +2547,12 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2188
2547
|
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor })
|
|
2189
2548
|
|
|
2190
2549
|
const hooks = {
|
|
2550
|
+
config: async (config) => {
|
|
2551
|
+
applyNativeGoalConfig(config, {
|
|
2552
|
+
...pluginOptions,
|
|
2553
|
+
requireVerifierOwnership: Boolean(pluginOptions.completionAudit),
|
|
2554
|
+
})
|
|
2555
|
+
},
|
|
2191
2556
|
"command.execute.before": async (input, output) => {
|
|
2192
2557
|
if (input.command !== commandName) return
|
|
2193
2558
|
|
|
@@ -2313,6 +2678,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2313
2678
|
]
|
|
2314
2679
|
return
|
|
2315
2680
|
}
|
|
2681
|
+
if (newObjective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
2682
|
+
output.parts = [makeTextPart(`Goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
|
|
2683
|
+
return
|
|
2684
|
+
}
|
|
2316
2685
|
|
|
2317
2686
|
goal.condition = newObjective
|
|
2318
2687
|
// Editing the objective revises the goal in place: keep the turn,
|
|
@@ -2360,6 +2729,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2360
2729
|
]
|
|
2361
2730
|
return
|
|
2362
2731
|
}
|
|
2732
|
+
if (objectives.some((objective) => objective.length > MAX_GOAL_OBJECTIVE_LENGTH)) {
|
|
2733
|
+
output.parts = [makeTextPart(`Each goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
|
|
2734
|
+
return
|
|
2735
|
+
}
|
|
2363
2736
|
|
|
2364
2737
|
// Replace any existing live goals for this session with the ordered set.
|
|
2365
2738
|
for (const existing of listSessionGoals(sessionID)) {
|
|
@@ -2531,6 +2904,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2531
2904
|
// goal and add another. Clear any ordered-sequence flag so the new
|
|
2532
2905
|
// standalone goal does not trigger sisyphus auto-promotion of old sequence
|
|
2533
2906
|
// goals that may still be in the registry (matches the agent setGoal path).
|
|
2907
|
+
const replacedGoal = goalStates.get(sessionID)
|
|
2534
2908
|
sessionOrdered.delete(sessionID)
|
|
2535
2909
|
cleanupGoal(sessionID)
|
|
2536
2910
|
lastGoalResults.delete(sessionID)
|
|
@@ -2540,6 +2914,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2540
2914
|
output.parts = [
|
|
2541
2915
|
makeTextPart(
|
|
2542
2916
|
[
|
|
2917
|
+
...(replacedGoal
|
|
2918
|
+
? [
|
|
2919
|
+
`⚠️ Replacing active goal: "${replacedGoal.condition}"`,
|
|
2920
|
+
`Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
|
|
2921
|
+
"",
|
|
2922
|
+
]
|
|
2923
|
+
: []),
|
|
2543
2924
|
`New active goal: ${goal.condition}`,
|
|
2544
2925
|
goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
|
|
2545
2926
|
goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
|
|
@@ -2561,6 +2942,20 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2561
2942
|
},
|
|
2562
2943
|
|
|
2563
2944
|
event: async ({ event }) => {
|
|
2945
|
+
if (isAbortErrorEvent(event)) {
|
|
2946
|
+
const sessionID = getSessionID(event)
|
|
2947
|
+
const goal = goalStates.get(sessionID)
|
|
2948
|
+
if (!goal) return
|
|
2949
|
+
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
2950
|
+
goal.stopped = true
|
|
2951
|
+
goal.stopReason = "user interrupted"
|
|
2952
|
+
goal.lastStatus = `Goal paused after user interruption. Run /${commandName} resume to continue.`
|
|
2953
|
+
pushHistory(goal, "paused", "Paused after OpenCode reported that the user interrupted the active turn.")
|
|
2954
|
+
activeContinues.delete(sessionID)
|
|
2955
|
+
await persist()
|
|
2956
|
+
return
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2564
2959
|
if (event.type === "message.updated") {
|
|
2565
2960
|
const message = messageInfoFromEvent(event)
|
|
2566
2961
|
if (!message) return
|
|
@@ -2584,6 +2979,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2584
2979
|
const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
|
|
2585
2980
|
const currentTokens = totalTokensForMessage(message)
|
|
2586
2981
|
const previousTokens = seenTokens.get(currentMessageID) || 0
|
|
2982
|
+
const currentUsage = normalizeMessageUsage(message)
|
|
2983
|
+
const previousUsage = seenUsage.get(currentMessageID) || emptyUsage()
|
|
2984
|
+
if (USAGE_TOKEN_FIELDS.some((field) => currentUsage[field] > previousUsage[field]) || currentUsage.cost > previousUsage.cost) {
|
|
2985
|
+
goal.usage = addUsageDelta(goal.usage, currentUsage, previousUsage)
|
|
2986
|
+
seenUsage.set(currentMessageID, currentUsage)
|
|
2987
|
+
goal.messageIDs.add(currentMessageID)
|
|
2988
|
+
changed = true
|
|
2989
|
+
}
|
|
2587
2990
|
if (currentTokens > previousTokens) {
|
|
2588
2991
|
// Track the context window size (peak input+output+reasoning),
|
|
2589
2992
|
// not cumulative API token consumption. Each message's tokens
|
|
@@ -2615,21 +3018,34 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2615
3018
|
if (!isIdleEvent(event)) return
|
|
2616
3019
|
|
|
2617
3020
|
const sessionID = getSessionID(event)
|
|
3021
|
+
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
3022
|
+
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
3023
|
+
if (eventID && seenIdleEventIDs.has(eventID)) return
|
|
3024
|
+
if (eventID) {
|
|
3025
|
+
seenIdleEventIDs.add(eventID)
|
|
3026
|
+
// Keep diagnostics bounded for long-running servers. Event IDs are only
|
|
3027
|
+
// needed to coalesce host re-delivery, not as durable history.
|
|
3028
|
+
if (seenIdleEventIDs.size > 256) {
|
|
3029
|
+
seenIdleEventIDs.delete(seenIdleEventIDs.values().next().value)
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
2618
3032
|
const goal = goalStates.get(sessionID)
|
|
2619
3033
|
if (!goal || goal.stopped || activeContinues.has(sessionID)) return
|
|
2620
3034
|
const goalID = goal.goalId
|
|
3035
|
+
const runID = goal.runId
|
|
2621
3036
|
|
|
2622
3037
|
const continueToken = randomUUID()
|
|
3038
|
+
const continueController = new AbortController()
|
|
2623
3039
|
activeContinues.set(sessionID, continueToken)
|
|
3040
|
+
currentRuntime().continuationControllers.set(sessionID, continueController)
|
|
2624
3041
|
try {
|
|
2625
|
-
const messages = await
|
|
2626
|
-
|
|
2627
|
-
query: { limit: goal.options.maxRecentMessages },
|
|
3042
|
+
const messages = await sessionApi.messages(sessionID, {
|
|
3043
|
+
limit: goal.options.maxRecentMessages,
|
|
2628
3044
|
})
|
|
2629
|
-
const activeGoalAfterMessages = activeGoal(sessionID, goalID)
|
|
3045
|
+
const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
|
|
2630
3046
|
if (!activeGoalAfterMessages) return
|
|
2631
3047
|
|
|
2632
|
-
const latestAssistant = findLatestAssistantMessage(messages
|
|
3048
|
+
const latestAssistant = findLatestAssistantMessage(messages)
|
|
2633
3049
|
const latestAssistantID = latestAssistant?.info?.id || ""
|
|
2634
3050
|
const latestText = getText(latestAssistant?.parts)
|
|
2635
3051
|
const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
|
|
@@ -2647,7 +3063,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2647
3063
|
// Latest instruction wins: if a real (non-plugin) user message arrived
|
|
2648
3064
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
2649
3065
|
// human. They can /goal resume to hand control back to the plugin.
|
|
2650
|
-
if (userInterventionDetected(messages
|
|
3066
|
+
if (userInterventionDetected(messages, activeGoalAfterMessages)) {
|
|
2651
3067
|
activeGoalAfterMessages.stopped = true
|
|
2652
3068
|
activeGoalAfterMessages.stopReason = "user intervention"
|
|
2653
3069
|
activeGoalAfterMessages.lastStatus =
|
|
@@ -2680,7 +3096,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2680
3096
|
// for the user to /goal clear or replace the goal. If it's gone,
|
|
2681
3097
|
// bail out without archiving — archiving a cleared goal would resurrect
|
|
2682
3098
|
// it in memory and potentially in the persisted state.
|
|
2683
|
-
if (!activeGoal(sessionID, goalID)) return
|
|
3099
|
+
if (!activeGoal(sessionID, goalID, runID)) return
|
|
2684
3100
|
// Optional independent auditor (item 2.2): an approved verdict
|
|
2685
3101
|
// archives; a rejected verdict restores (pauses) the goal instead.
|
|
2686
3102
|
if (completionAuditor) {
|
|
@@ -2691,7 +3107,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2691
3107
|
await logPluginError(client, "Completion auditor threw", error)
|
|
2692
3108
|
verdict = { approved: false, reason: "auditor error" }
|
|
2693
3109
|
}
|
|
2694
|
-
const auditedGoal = activeGoal(sessionID, goalID)
|
|
3110
|
+
const auditedGoal = activeGoal(sessionID, goalID, runID)
|
|
2695
3111
|
if (!auditedGoal) {
|
|
2696
3112
|
// The goal was cleared or replaced while the auditor was running.
|
|
2697
3113
|
// If the verdict was approved, surface the loss so the user knows
|
|
@@ -2788,9 +3204,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2788
3204
|
activeGoalAfterMessages.stopReason = limitReason
|
|
2789
3205
|
activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
|
|
2790
3206
|
pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
|
|
2791
|
-
await
|
|
2792
|
-
|
|
2793
|
-
body: { parts: [makeTextPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))] },
|
|
3207
|
+
await sessionApi.promptAsync(sessionID, {
|
|
3208
|
+
parts: [makeContinuationPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))],
|
|
2794
3209
|
})
|
|
2795
3210
|
} else {
|
|
2796
3211
|
activeGoalAfterMessages.stopped = true
|
|
@@ -2912,10 +3327,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2912
3327
|
activeGoalAfterMessages.lastContinueAt &&
|
|
2913
3328
|
elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs
|
|
2914
3329
|
) {
|
|
2915
|
-
await sleep(
|
|
3330
|
+
const delayCompleted = await sleep(
|
|
3331
|
+
activeGoalAfterMessages.options.minDelayMs - elapsedSinceLastContinue,
|
|
3332
|
+
continueController.signal,
|
|
3333
|
+
)
|
|
3334
|
+
if (!delayCompleted) return
|
|
2916
3335
|
}
|
|
2917
3336
|
|
|
2918
|
-
const activeGoalBeforePrompt = activeGoal(sessionID, goalID)
|
|
3337
|
+
const activeGoalBeforePrompt = activeGoal(sessionID, goalID, runID)
|
|
2919
3338
|
if (!activeGoalBeforePrompt) return
|
|
2920
3339
|
|
|
2921
3340
|
const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
|
|
@@ -2973,23 +3392,20 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2973
3392
|
}
|
|
2974
3393
|
}
|
|
2975
3394
|
|
|
2976
|
-
const response = await
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
),
|
|
2987
|
-
],
|
|
2988
|
-
},
|
|
3395
|
+
const response = await sessionApi.promptAsync(sessionID, {
|
|
3396
|
+
parts: [
|
|
3397
|
+
makeContinuationPart(
|
|
3398
|
+
buildContinueMessage(activeGoalBeforePrompt, {
|
|
3399
|
+
budgetWrapup,
|
|
3400
|
+
completionUnverified,
|
|
3401
|
+
blockerUnstated,
|
|
3402
|
+
}),
|
|
3403
|
+
),
|
|
3404
|
+
],
|
|
2989
3405
|
})
|
|
2990
3406
|
|
|
2991
3407
|
if (response.error) {
|
|
2992
|
-
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
3408
|
+
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
2993
3409
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
2994
3410
|
if (activeGoalAfterPrompt) {
|
|
2995
3411
|
activeGoalAfterPrompt.promptFailures += 1
|
|
@@ -3003,7 +3419,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
3003
3419
|
}
|
|
3004
3420
|
await logPluginError(client, message, response.error)
|
|
3005
3421
|
} else {
|
|
3006
|
-
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
3422
|
+
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
3007
3423
|
if (activeGoalAfterPrompt) {
|
|
3008
3424
|
// Decrement rather than reset: an alternating error/success pattern
|
|
3009
3425
|
// should still accumulate toward the circuit-breaker cap over time,
|
|
@@ -3020,7 +3436,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
3020
3436
|
}
|
|
3021
3437
|
await persist()
|
|
3022
3438
|
} catch (error) {
|
|
3023
|
-
const activeGoalAfterError = currentGoal(sessionID, goalID)
|
|
3439
|
+
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
3024
3440
|
if (activeGoalAfterError) {
|
|
3025
3441
|
activeGoalAfterError.promptFailures += 1
|
|
3026
3442
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
@@ -3039,6 +3455,9 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
3039
3455
|
// the goal completed) and a new handler has since set a fresh token,
|
|
3040
3456
|
// we must not clobber the new handler's guard.
|
|
3041
3457
|
if (activeContinues.get(sessionID) === continueToken) activeContinues.delete(sessionID)
|
|
3458
|
+
if (currentRuntime().continuationControllers.get(sessionID) === continueController) {
|
|
3459
|
+
currentRuntime().continuationControllers.delete(sessionID)
|
|
3460
|
+
}
|
|
3042
3461
|
}
|
|
3043
3462
|
},
|
|
3044
3463
|
|
|
@@ -3142,6 +3561,51 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
3142
3561
|
return hooks
|
|
3143
3562
|
}
|
|
3144
3563
|
|
|
3564
|
+
function bindRuntime(runtime, handler) {
|
|
3565
|
+
return (...args) => runtimeStorage.run(runtime, () => handler(...args))
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
function bindHooksToRuntime(hooks, runtime) {
|
|
3569
|
+
const bound = {}
|
|
3570
|
+
for (const [name, value] of Object.entries(hooks)) {
|
|
3571
|
+
if (name === "tool" && value && typeof value === "object") {
|
|
3572
|
+
bound.tool = Object.fromEntries(
|
|
3573
|
+
Object.entries(value).map(([toolName, definition]) => {
|
|
3574
|
+
if (!definition || typeof definition.execute !== "function") return [toolName, definition]
|
|
3575
|
+
return [
|
|
3576
|
+
toolName,
|
|
3577
|
+
{
|
|
3578
|
+
...definition,
|
|
3579
|
+
execute: bindRuntime(runtime, definition.execute),
|
|
3580
|
+
},
|
|
3581
|
+
]
|
|
3582
|
+
}),
|
|
3583
|
+
)
|
|
3584
|
+
continue
|
|
3585
|
+
}
|
|
3586
|
+
bound[name] = typeof value === "function" ? bindRuntime(runtime, value) : value
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
bound.dispose = bindRuntime(runtime, async () => {
|
|
3590
|
+
if (runtime.disposed) return
|
|
3591
|
+
runtime.disposed = true
|
|
3592
|
+
clearRuntimeState()
|
|
3593
|
+
setLedgerSink(null)
|
|
3594
|
+
await runtime.persistenceLease?.release()
|
|
3595
|
+
runtime.persistenceLease = null
|
|
3596
|
+
})
|
|
3597
|
+
return bound
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
export const GoalPlugin = async (context = {}, pluginOptions = {}) => {
|
|
3601
|
+
const runtime = createRuntimeState()
|
|
3602
|
+
lastRuntime = runtime
|
|
3603
|
+
return runtimeStorage.run(runtime, async () => {
|
|
3604
|
+
const hooks = await createGoalPlugin(context, pluginOptions)
|
|
3605
|
+
return bindHooksToRuntime(hooks, runtime)
|
|
3606
|
+
})
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3145
3609
|
export default {
|
|
3146
3610
|
id: "opencode-goal-plugin",
|
|
3147
3611
|
server: GoalPlugin,
|
|
@@ -3152,6 +3616,7 @@ export const testInternals = {
|
|
|
3152
3616
|
agentToolSessionID,
|
|
3153
3617
|
buildAgentToolHandlers,
|
|
3154
3618
|
buildAgentTools,
|
|
3619
|
+
serializeCompletionClaim,
|
|
3155
3620
|
listSessionGoals,
|
|
3156
3621
|
formatGoalList,
|
|
3157
3622
|
appendLedgerLine,
|
|
@@ -3189,6 +3654,8 @@ export const testInternals = {
|
|
|
3189
3654
|
normalizeCommandOptions,
|
|
3190
3655
|
normalizeMode,
|
|
3191
3656
|
normalizeOptions,
|
|
3657
|
+
normalizeMessageUsage,
|
|
3658
|
+
normalizeUsage,
|
|
3192
3659
|
normalizePersistenceOptions,
|
|
3193
3660
|
userInterventionDetected,
|
|
3194
3661
|
outputTokensForMessage,
|