opencode-goal-plugin 0.8.2 → 0.10.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 +18 -0
- package/README.md +98 -9
- package/docs/compatibility.md +50 -0
- package/docs/releasing.md +4 -1
- package/examples/opencode.json +1 -1
- package/index.d.ts +52 -0
- package/package.json +2 -2
- package/scripts/verify.mjs +78 -1
- package/src/goal-plugin.js +509 -48
package/src/goal-plugin.js
CHANGED
|
@@ -73,6 +73,9 @@ const DEFAULT_OPTIONS = {
|
|
|
73
73
|
maxTurns: 10,
|
|
74
74
|
maxDurationMs: 15 * 60 * 1000,
|
|
75
75
|
maxTokens: 200000,
|
|
76
|
+
// Cumulative OpenCode-reported API cost, in US dollars, before the goal
|
|
77
|
+
// pauses. 0 disables the cap; enforcement depends on provider cost metadata.
|
|
78
|
+
maxCostUsd: 0,
|
|
76
79
|
minDelayMs: 1500,
|
|
77
80
|
maxRecentMessages: 50,
|
|
78
81
|
noProgressTokenThreshold: 50,
|
|
@@ -111,6 +114,11 @@ function createRuntimeState() {
|
|
|
111
114
|
seenIdleEventIDs: new Set(),
|
|
112
115
|
sessionStatuses: new Map(),
|
|
113
116
|
sessionExecutionContexts: new Map(),
|
|
117
|
+
// Session-title indicator: the user's own title, captured before the plugin
|
|
118
|
+
// first overwrites it, and the last title the plugin wrote (so an unchanged
|
|
119
|
+
// render skips the API call).
|
|
120
|
+
sessionTitles: new Map(),
|
|
121
|
+
appliedTitles: new Map(),
|
|
114
122
|
pendingCommandTurns: new Map(),
|
|
115
123
|
activeCommandTurns: new Map(),
|
|
116
124
|
commandOutputs: new WeakMap(),
|
|
@@ -223,6 +231,8 @@ const GOAL_FLAG_SPECS = {
|
|
|
223
231
|
// Inline budget shorthand for the context-token limit. Accepts a plain
|
|
224
232
|
// integer or a k/m suffix (e.g. --budget 100k == --max-tokens 100000).
|
|
225
233
|
"--budget": { type: "tokens", optionKey: "maxTokens" },
|
|
234
|
+
// Per-goal cost cap in US dollars (e.g. --max-cost 5 or --max-cost 2.50).
|
|
235
|
+
"--max-cost": { type: "usd", optionKey: "maxCostUsd" },
|
|
226
236
|
"--success": { type: "string", target: "meta", metaKey: "successCriteria" },
|
|
227
237
|
"--success-criteria": { type: "string", target: "meta", metaKey: "successCriteria" },
|
|
228
238
|
"--constraints": { type: "string", target: "meta", metaKey: "constraints" },
|
|
@@ -298,6 +308,47 @@ function frameControlCommandText(text) {
|
|
|
298
308
|
].join("\n")
|
|
299
309
|
}
|
|
300
310
|
|
|
311
|
+
// Routed text for the turn that creates a goal. A held goal must not be told
|
|
312
|
+
// to start working: command text reaches the model as a normal turn on current
|
|
313
|
+
// OpenCode builds, so that line would be the escape the plan guard exists to
|
|
314
|
+
// prevent.
|
|
315
|
+
function buildGoalCommandNotice(goal, { heldLabel = "", replacedGoal = null, commandName = "goal" } = {}) {
|
|
316
|
+
return [
|
|
317
|
+
...(replacedGoal
|
|
318
|
+
? [
|
|
319
|
+
`⚠️ Replacing active goal: "${replacedGoal.condition}"`,
|
|
320
|
+
`Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
|
|
321
|
+
"",
|
|
322
|
+
]
|
|
323
|
+
: []),
|
|
324
|
+
heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
|
|
325
|
+
goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
|
|
326
|
+
goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
|
|
327
|
+
goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
|
|
328
|
+
"",
|
|
329
|
+
...(heldLabel
|
|
330
|
+
? [
|
|
331
|
+
`The ${heldLabel} agent is planning-only, so this goal is not running.`,
|
|
332
|
+
"Do not begin work on it now. Continue planning only.",
|
|
333
|
+
`Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
|
|
334
|
+
]
|
|
335
|
+
: [
|
|
336
|
+
"Start working toward this goal now.",
|
|
337
|
+
"When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.",
|
|
338
|
+
"If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
|
|
339
|
+
]),
|
|
340
|
+
`Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
|
|
341
|
+
"",
|
|
342
|
+
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
343
|
+
goal.options.maxDurationMs / 1000,
|
|
344
|
+
)}s, ${goal.options.maxTokens.toLocaleString()} context tokens${
|
|
345
|
+
goal.options.maxCostUsd > 0 ? `, $${goal.options.maxCostUsd.toFixed(2)} cost` : ""
|
|
346
|
+
}.`,
|
|
347
|
+
]
|
|
348
|
+
.filter((line) => line !== null)
|
|
349
|
+
.join("\n")
|
|
350
|
+
}
|
|
351
|
+
|
|
301
352
|
// OpenCode retains its original command-parts array after invoking
|
|
302
353
|
// command.execute.before. Reassigning output.parts therefore changes only the
|
|
303
354
|
// temporary wrapper passed to the plugin, while the host still sends the raw
|
|
@@ -396,8 +447,107 @@ function continuationContextInput(goal) {
|
|
|
396
447
|
return context ? { ...context } : {}
|
|
397
448
|
}
|
|
398
449
|
|
|
450
|
+
// Planning-only agents must never be driven into execution by the goal loop.
|
|
451
|
+
// `plan` is OpenCode's built-in read-only agent; `restrictedAgents` lets a
|
|
452
|
+
// deployment name others (for example a review-only agent).
|
|
453
|
+
const DEFAULT_RESTRICTED_AGENTS = ["plan"]
|
|
454
|
+
|
|
455
|
+
function normalizeRestrictedAgents(value) {
|
|
456
|
+
// Anything that is not an array (including undefined) keeps the safe default;
|
|
457
|
+
// an explicit empty array is a deliberate opt-out.
|
|
458
|
+
if (!Array.isArray(value)) return [...DEFAULT_RESTRICTED_AGENTS]
|
|
459
|
+
const names = value
|
|
460
|
+
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
|
|
461
|
+
.filter(Boolean)
|
|
462
|
+
return [...new Set(names)]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function isRestrictedAgent(agent, restrictedAgents = DEFAULT_RESTRICTED_AGENTS) {
|
|
466
|
+
if (typeof agent !== "string") return false
|
|
467
|
+
const name = agent.trim().toLowerCase()
|
|
468
|
+
if (!name) return false
|
|
469
|
+
return restrictedAgents.includes(name)
|
|
470
|
+
}
|
|
471
|
+
|
|
399
472
|
function isPlanAgent(agent) {
|
|
400
|
-
return
|
|
473
|
+
return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Session-title status indicator. OpenCode renders the session title
|
|
477
|
+
// persistently, so mirroring goal progress into it gives unattended runs a
|
|
478
|
+
// continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it
|
|
479
|
+
// overwrites a user-visible field.
|
|
480
|
+
const SESSION_TITLE_OBJECTIVE_LIMIT = 48
|
|
481
|
+
const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔", "✅"]
|
|
482
|
+
|
|
483
|
+
// The title sits in a narrow column, so every field is abbreviated hard.
|
|
484
|
+
function formatCompactDuration(ms) {
|
|
485
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
|
486
|
+
if (totalSeconds < 60) return `${totalSeconds}s`
|
|
487
|
+
const totalMinutes = Math.floor(totalSeconds / 60)
|
|
488
|
+
if (totalMinutes < 60) return `${totalMinutes}m`
|
|
489
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
490
|
+
const minutes = totalMinutes % 60
|
|
491
|
+
return minutes ? `${hours}h${minutes}m` : `${hours}h`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function formatCompactTokens(tokens) {
|
|
495
|
+
const value = toNonNegativeInteger(tokens)
|
|
496
|
+
if (value < 1000) return String(value)
|
|
497
|
+
if (value < 1_000_000) {
|
|
498
|
+
const thousands = value / 1000
|
|
499
|
+
return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`
|
|
500
|
+
}
|
|
501
|
+
const millions = value / 1_000_000
|
|
502
|
+
return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m`
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Blocked and paused are distinct to a watching human: one needs input, the
|
|
506
|
+
// other just needs a resume.
|
|
507
|
+
function goalStatusIcon(goal) {
|
|
508
|
+
if (goal.blockedReason) return "⛔"
|
|
509
|
+
if (goal.stopped) return "⏸"
|
|
510
|
+
return "▶"
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// One-line goal status for the session title, e.g.
|
|
514
|
+
// "▶ ship the release · 3/10 · 2m · 45k/200k".
|
|
515
|
+
function buildSessionTitle(goal, now = Date.now()) {
|
|
516
|
+
const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt)
|
|
517
|
+
return [
|
|
518
|
+
`${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
|
|
519
|
+
`${goal.turnCount}/${goal.options.maxTurns}`,
|
|
520
|
+
formatCompactDuration(elapsedMs),
|
|
521
|
+
`${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`,
|
|
522
|
+
].join(" · ")
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Title for a goal that just completed. Archived results carry the counters
|
|
526
|
+
// but not the option snapshot, so the "/limit" halves are dropped.
|
|
527
|
+
function buildCompletedSessionTitle(result) {
|
|
528
|
+
const turns = toNonNegativeInteger(result.turnCount)
|
|
529
|
+
return [
|
|
530
|
+
`✅ ${summarizeText(result.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
|
|
531
|
+
`${turns} turn${turns === 1 ? "" : "s"}`,
|
|
532
|
+
formatCompactDuration(Math.max(0, result.finishedAt - result.startedAt)),
|
|
533
|
+
formatCompactTokens(result.totalTokens),
|
|
534
|
+
].join(" · ")
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Recognize a title this plugin wrote. The captured "original" is what
|
|
538
|
+
// `/goal clear` restores, so capturing one of our own status lines would make
|
|
539
|
+
// clear promote a stale status string to the permanent session title. That is
|
|
540
|
+
// exactly the state a hard process kill leaves behind.
|
|
541
|
+
function looksLikePluginSessionTitle(title) {
|
|
542
|
+
const text = typeof title === "string" ? title.trimStart() : ""
|
|
543
|
+
return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Stop reason for a goal held because a planning-only agent is active. The
|
|
547
|
+
// built-in `plan` case keeps its established wording so persisted state and
|
|
548
|
+
// existing consumers stay stable.
|
|
549
|
+
function restrictedAgentStopReason(agent) {
|
|
550
|
+
return isPlanAgent(agent) ? "plan agent active" : `${String(agent).trim().toLowerCase()} agent active`
|
|
401
551
|
}
|
|
402
552
|
|
|
403
553
|
function terminalEvent(event) {
|
|
@@ -725,6 +875,11 @@ function formatStatus(
|
|
|
725
875
|
`Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
|
|
726
876
|
`Context tokens: ${goal.totalTokens.toLocaleString()}/${goal.options.maxTokens.toLocaleString()}`,
|
|
727
877
|
formatUsage(goal.usage),
|
|
878
|
+
...(costCapFor(goal)
|
|
879
|
+
? [
|
|
880
|
+
`Cost budget: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(4)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}`,
|
|
881
|
+
]
|
|
882
|
+
: []),
|
|
728
883
|
`Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
|
|
729
884
|
`Last progress: ${lastProgress}`,
|
|
730
885
|
`No-progress turns: ${goal.noProgressTurns}`,
|
|
@@ -789,9 +944,26 @@ function stopReason(goal) {
|
|
|
789
944
|
return `max duration reached (${Math.round(goal.options.maxDurationMs / 1000)}s)`
|
|
790
945
|
}
|
|
791
946
|
if (goal.totalTokens >= goal.options.maxTokens) return `max context tokens reached (${goal.options.maxTokens.toLocaleString()})`
|
|
947
|
+
const costCap = costCapFor(goal)
|
|
948
|
+
if (costCap && costCap.reached) return `max cost reached ($${costCap.limit.toFixed(2)})`
|
|
792
949
|
return null
|
|
793
950
|
}
|
|
794
951
|
|
|
952
|
+
// Cost cap state, or null when the cap is disabled. The cap can only be
|
|
953
|
+
// enforced when the provider reports cost; an unknown cost never trips it.
|
|
954
|
+
function costCapFor(goal) {
|
|
955
|
+
const limit = Number(goal?.options?.maxCostUsd)
|
|
956
|
+
if (!Number.isFinite(limit) || limit <= 0) return null
|
|
957
|
+
const usage = normalizeUsage(goal.usage)
|
|
958
|
+
return {
|
|
959
|
+
limit,
|
|
960
|
+
spent: usage.cost,
|
|
961
|
+
known: usage.costKnown,
|
|
962
|
+
remaining: Math.max(0, limit - usage.cost),
|
|
963
|
+
reached: usage.costKnown && usage.cost >= limit,
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
795
967
|
function sessionGoalMap(sessionID) {
|
|
796
968
|
let map = sessionGoals.get(sessionID)
|
|
797
969
|
if (!map) {
|
|
@@ -920,6 +1092,8 @@ function clearRuntimeState() {
|
|
|
920
1092
|
runtime.seenIdleEventIDs.clear()
|
|
921
1093
|
runtime.sessionStatuses.clear()
|
|
922
1094
|
runtime.sessionExecutionContexts.clear()
|
|
1095
|
+
runtime.sessionTitles.clear()
|
|
1096
|
+
runtime.appliedTitles.clear()
|
|
923
1097
|
runtime.pendingCommandTurns.clear()
|
|
924
1098
|
runtime.activeCommandTurns.clear()
|
|
925
1099
|
runtime.ownedPluginMessages.clear()
|
|
@@ -1170,6 +1344,10 @@ function normalizeOptions(options = {}) {
|
|
|
1170
1344
|
maxTurns: toPositiveInteger(options.maxTurns, DEFAULT_OPTIONS.maxTurns),
|
|
1171
1345
|
maxDurationMs: toPositiveInteger(options.maxDurationMs, DEFAULT_OPTIONS.maxDurationMs),
|
|
1172
1346
|
maxTokens: toPositiveInteger(options.maxTokens, DEFAULT_OPTIONS.maxTokens),
|
|
1347
|
+
maxCostUsd:
|
|
1348
|
+
Number.isFinite(Number(options.maxCostUsd)) && Number(options.maxCostUsd) > 0
|
|
1349
|
+
? Number(options.maxCostUsd)
|
|
1350
|
+
: DEFAULT_OPTIONS.maxCostUsd,
|
|
1173
1351
|
minDelayMs: toPositiveInteger(options.minDelayMs, DEFAULT_OPTIONS.minDelayMs),
|
|
1174
1352
|
maxRecentMessages: toPositiveInteger(
|
|
1175
1353
|
options.maxRecentMessages,
|
|
@@ -2133,29 +2311,55 @@ async function logPluginWarning(client, message) {
|
|
|
2133
2311
|
return logPluginMessage(client, "warn", message)
|
|
2134
2312
|
}
|
|
2135
2313
|
|
|
2314
|
+
// Cosmetic failures (session-title updates) log at debug and never fall back to
|
|
2315
|
+
// the console: a title that failed to render must not look like a goal fault.
|
|
2316
|
+
async function logPluginDebug(client, message, error) {
|
|
2317
|
+
if (!client?.app?.log) return
|
|
2318
|
+
try {
|
|
2319
|
+
await client.app.log({
|
|
2320
|
+
body: {
|
|
2321
|
+
service: "opencode-goal-plugin",
|
|
2322
|
+
level: "debug",
|
|
2323
|
+
message,
|
|
2324
|
+
...(error === undefined
|
|
2325
|
+
? {}
|
|
2326
|
+
: { extra: { error: error?.message || error?.name || String(error) } }),
|
|
2327
|
+
},
|
|
2328
|
+
})
|
|
2329
|
+
} catch {
|
|
2330
|
+
// Diagnostics must never affect the goal loop.
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// A fenced ```span``` in the arguments is objective text verbatim: double-dash
|
|
2335
|
+
// tokens inside it are never parsed as goal flags and it is never consumed as
|
|
2336
|
+
// a flag value, so a command line can be quoted inside an objective.
|
|
2136
2337
|
function parseGoalArguments(args, defaults) {
|
|
2137
|
-
const parts =
|
|
2338
|
+
const parts = Array.from(
|
|
2339
|
+
args.matchAll(/```([\s\S]*?)```|"[^"]*"|'[^']*'|\S+/g),
|
|
2340
|
+
(match) => ({ value: match[1] ?? match[0], literal: match[1] !== undefined }),
|
|
2341
|
+
)
|
|
2138
2342
|
const condition = []
|
|
2139
2343
|
const options = { ...defaults }
|
|
2140
2344
|
const meta = { ...GOAL_META_DEFAULTS }
|
|
2141
2345
|
const errors = []
|
|
2346
|
+
const isFlagValue = (candidate) =>
|
|
2347
|
+
candidate !== undefined && !candidate.literal && !candidate.value.startsWith("--")
|
|
2142
2348
|
|
|
2143
2349
|
for (let i = 0; i < parts.length; i += 1) {
|
|
2144
|
-
const part = parts[i]
|
|
2350
|
+
const { value: part, literal } = parts[i]
|
|
2145
2351
|
|
|
2146
|
-
if (part.startsWith("--")) {
|
|
2352
|
+
if (!literal && part.startsWith("--")) {
|
|
2147
2353
|
const [flagName, inlineValue] = part.split(/=(.*)/s, 2)
|
|
2148
2354
|
const flagSpec = GOAL_FLAG_SPECS[flagName]
|
|
2149
2355
|
|
|
2150
2356
|
if (!flagSpec) {
|
|
2151
|
-
|
|
2152
|
-
if (inlineValue === undefined && next !== undefined && !next.startsWith("--")) i += 1
|
|
2357
|
+
if (inlineValue === undefined && isFlagValue(parts[i + 1])) i += 1
|
|
2153
2358
|
errors.push(`Unsupported flag: ${flagName}`)
|
|
2154
2359
|
continue
|
|
2155
2360
|
}
|
|
2156
2361
|
|
|
2157
|
-
const
|
|
2158
|
-
const value = inlineValue ?? (next !== undefined && !next.startsWith("--") ? next : undefined)
|
|
2362
|
+
const value = inlineValue ?? (isFlagValue(parts[i + 1]) ? parts[i + 1].value : undefined)
|
|
2159
2363
|
if (inlineValue === undefined && value !== undefined) i += 1
|
|
2160
2364
|
|
|
2161
2365
|
if (value === undefined) {
|
|
@@ -2177,6 +2381,16 @@ function parseGoalArguments(args, defaults) {
|
|
|
2177
2381
|
continue
|
|
2178
2382
|
}
|
|
2179
2383
|
|
|
2384
|
+
if (flagSpec.type === "usd") {
|
|
2385
|
+
const cost = /^\$?\d+(?:\.\d+)?$/.test(rawValue.trim()) ? Number(rawValue.trim().replace(/^\$/, "")) : NaN
|
|
2386
|
+
if (!Number.isFinite(cost) || cost <= 0) {
|
|
2387
|
+
errors.push(`Invalid cost budget for ${flagName}: ${value} (use a positive number of US dollars)`)
|
|
2388
|
+
continue
|
|
2389
|
+
}
|
|
2390
|
+
options[flagSpec.optionKey] = cost
|
|
2391
|
+
continue
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2180
2394
|
if (flagSpec.type === "string") {
|
|
2181
2395
|
const text = rawValue.trim()
|
|
2182
2396
|
if (!text) {
|
|
@@ -2207,7 +2421,7 @@ function parseGoalArguments(args, defaults) {
|
|
|
2207
2421
|
continue
|
|
2208
2422
|
}
|
|
2209
2423
|
|
|
2210
|
-
condition.push(stripWrappingQuotes(part))
|
|
2424
|
+
condition.push(literal ? part.trim() : stripWrappingQuotes(part))
|
|
2211
2425
|
}
|
|
2212
2426
|
|
|
2213
2427
|
const parsedCondition = condition.join(" ").trim()
|
|
@@ -2258,6 +2472,10 @@ function buildLimitWarning(goal) {
|
|
|
2258
2472
|
if (remainingTokens <= goal.options.warnTokensRemaining) {
|
|
2259
2473
|
warnings.push(`${Math.max(0, remainingTokens).toLocaleString()} context token(s) remaining`)
|
|
2260
2474
|
}
|
|
2475
|
+
const costCap = costCapFor(goal)
|
|
2476
|
+
if (costCap?.known && costCap.remaining <= costCap.limit * 0.1) {
|
|
2477
|
+
warnings.push(`$${costCap.remaining.toFixed(2)} of the $${costCap.limit.toFixed(2)} cost budget remaining`)
|
|
2478
|
+
}
|
|
2261
2479
|
|
|
2262
2480
|
return warnings.length ? ` Limits are near: ${warnings.join(", ")}.` : ""
|
|
2263
2481
|
}
|
|
@@ -2351,6 +2569,9 @@ function buildContinueMessage(
|
|
|
2351
2569
|
"<progress_budget>",
|
|
2352
2570
|
`turns_remaining: ${remainingTurns}`,
|
|
2353
2571
|
`tokens_remaining: ${remainingTokens}`,
|
|
2572
|
+
...(costCapFor(goal)
|
|
2573
|
+
? [`cost_remaining_usd: ${costCapFor(goal).known ? costCapFor(goal).remaining.toFixed(2) : "unknown"}`]
|
|
2574
|
+
: []),
|
|
2354
2575
|
`elapsed_seconds: ${elapsedSeconds}`,
|
|
2355
2576
|
"</progress_budget>",
|
|
2356
2577
|
]
|
|
@@ -2442,7 +2663,9 @@ function buildCompactionContext(goal) {
|
|
|
2442
2663
|
"The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.",
|
|
2443
2664
|
buildGoalBlock(goal),
|
|
2444
2665
|
`Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
|
|
2445
|
-
`Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s
|
|
2666
|
+
`Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.${
|
|
2667
|
+
costCapFor(goal) ? ` Cost: ${costCapFor(goal).known ? `$${costCapFor(goal).spent.toFixed(2)}` : "unknown"}/$${costCapFor(goal).limit.toFixed(2)}.` : ""
|
|
2668
|
+
}`,
|
|
2446
2669
|
goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(summarizeText(goal.lastCheckpoint.summary, 200))}` : null,
|
|
2447
2670
|
...buildCompactionProgressSummary(goal),
|
|
2448
2671
|
"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.",
|
|
@@ -3035,7 +3258,32 @@ function buildAgentToolHandlers({
|
|
|
3035
3258
|
auditMessagesEnabled = false,
|
|
3036
3259
|
announceLifecycle = () => {},
|
|
3037
3260
|
commandName = "goal",
|
|
3261
|
+
agentGoalAuthority = "full",
|
|
3038
3262
|
}) {
|
|
3263
|
+
// "status" authority: agents may report on a goal (complete, block, pause,
|
|
3264
|
+
// resume) and create one when none is live, but only the user, through the
|
|
3265
|
+
// slash command, may replace, edit, or clear a goal. Returns the refusal
|
|
3266
|
+
// text, or null when the action is allowed.
|
|
3267
|
+
function agentLockMessage(sessionID, action) {
|
|
3268
|
+
if (agentGoalAuthority !== "status") return null
|
|
3269
|
+
if (action === "replace" && !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0) {
|
|
3270
|
+
return null
|
|
3271
|
+
}
|
|
3272
|
+
const verb =
|
|
3273
|
+
action === "replace"
|
|
3274
|
+
? "replace the active goal"
|
|
3275
|
+
: action === "edit"
|
|
3276
|
+
? "change the goal objective"
|
|
3277
|
+
: "clear the goal"
|
|
3278
|
+
const hint =
|
|
3279
|
+
action === "replace"
|
|
3280
|
+
? `/${commandName} <objective>, /${commandName} add <objective>, or /${commandName} edit <objective>`
|
|
3281
|
+
: action === "edit"
|
|
3282
|
+
? `/${commandName} edit <objective>`
|
|
3283
|
+
: `/${commandName} clear`
|
|
3284
|
+
return `Agents cannot ${verb} in this session (agentGoalAuthority: "status"). Ask the user to run ${hint}.`
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3039
3287
|
// Use persistTerminalState (which logs on failure) for terminal operations when
|
|
3040
3288
|
// available; fall back to plain persist for callers that don't wire it up (e.g.
|
|
3041
3289
|
// tests using buildAgentToolHandlers directly).
|
|
@@ -3075,6 +3323,8 @@ function buildAgentToolHandlers({
|
|
|
3075
3323
|
async function setGoal(sessionID, args = {}) {
|
|
3076
3324
|
const objective = typeof args.objective === "string" ? args.objective.trim() : ""
|
|
3077
3325
|
if (!objective) return "No objective provided. Pass a non-empty `objective`."
|
|
3326
|
+
const replaceLock = agentLockMessage(sessionID, "replace")
|
|
3327
|
+
if (replaceLock) return replaceLock
|
|
3078
3328
|
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH)
|
|
3079
3329
|
return `Invalid objective: must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`
|
|
3080
3330
|
for (const [field, value] of [["successCriteria", args.successCriteria], ["constraints", args.constraints]]) {
|
|
@@ -3090,6 +3340,8 @@ function buildAgentToolHandlers({
|
|
|
3090
3340
|
return `Invalid maxTokens: ${args.maxTokens} — must be a positive integer.`
|
|
3091
3341
|
if (Number.isFinite(args.maxDurationMs) && args.maxDurationMs <= 0)
|
|
3092
3342
|
return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
|
|
3343
|
+
if (Number.isFinite(args.maxCostUsd) && args.maxCostUsd <= 0)
|
|
3344
|
+
return `Invalid maxCostUsd: ${args.maxCostUsd} — must be a positive number of US dollars.`
|
|
3093
3345
|
if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
|
|
3094
3346
|
return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
|
|
3095
3347
|
const options = normalizeOptions({
|
|
@@ -3097,6 +3349,7 @@ function buildAgentToolHandlers({
|
|
|
3097
3349
|
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
3098
3350
|
...(Number.isFinite(args.maxTokens) ? { maxTokens: args.maxTokens } : {}),
|
|
3099
3351
|
...(Number.isFinite(args.maxDurationMs) ? { maxDurationMs: args.maxDurationMs } : {}),
|
|
3352
|
+
...(Number.isFinite(args.maxCostUsd) ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
3100
3353
|
})
|
|
3101
3354
|
const meta = {
|
|
3102
3355
|
successCriteria: typeof args.successCriteria === "string" ? args.successCriteria : "",
|
|
@@ -3134,6 +3387,10 @@ function buildAgentToolHandlers({
|
|
|
3134
3387
|
async function updateGoal(sessionID, args = {}) {
|
|
3135
3388
|
let goal = goalStates.get(sessionID)
|
|
3136
3389
|
if (!goal) return "No active goal to update. Use set_goal first."
|
|
3390
|
+
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
3391
|
+
const editLock = agentLockMessage(sessionID, "edit")
|
|
3392
|
+
if (editLock) return editLock
|
|
3393
|
+
}
|
|
3137
3394
|
|
|
3138
3395
|
// Reject the combination of an objective update with status='complete': the
|
|
3139
3396
|
// completion would be archived under a condition that was never executed,
|
|
@@ -3439,6 +3696,8 @@ function buildAgentToolHandlers({
|
|
|
3439
3696
|
}
|
|
3440
3697
|
|
|
3441
3698
|
async function clearGoal(sessionID) {
|
|
3699
|
+
const clearLock = agentLockMessage(sessionID, "clear")
|
|
3700
|
+
if (clearLock) return clearLock
|
|
3442
3701
|
// Mirror `/goal clear`: drop the ordered flag, ALL backgrounded goals, and the
|
|
3443
3702
|
// focused goal + result. Without sessionGoals.delete, background goals added via
|
|
3444
3703
|
// `/goal add` survive clear and resurrect as the focused goal on restart.
|
|
@@ -3472,7 +3731,7 @@ function buildAgentToolHandlers({
|
|
|
3472
3731
|
: "Goal cleared."
|
|
3473
3732
|
}
|
|
3474
3733
|
|
|
3475
|
-
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
|
|
3734
|
+
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal, agentLockMessage }
|
|
3476
3735
|
}
|
|
3477
3736
|
|
|
3478
3737
|
function agentToolSessionID(ctx) {
|
|
@@ -3582,6 +3841,8 @@ function buildAgentTools(
|
|
|
3582
3841
|
if (typeof args.objective !== "string" || !args.objective.trim()) {
|
|
3583
3842
|
return goalToolFailure("invalid_objective", "No objective provided. Pass a non-empty objective.")
|
|
3584
3843
|
}
|
|
3844
|
+
const locked = handlers.agentLockMessage?.(sessionID, "replace")
|
|
3845
|
+
if (locked) return goalToolFailure("agent_authority", locked)
|
|
3585
3846
|
return goalToolSuccess(await handlers.setGoal(sessionID, args))
|
|
3586
3847
|
},
|
|
3587
3848
|
update: async (sessionID, args) => {
|
|
@@ -3628,6 +3889,7 @@ function buildAgentTools(
|
|
|
3628
3889
|
maxTurns: schema.number().optional(),
|
|
3629
3890
|
maxTokens: schema.number().optional(),
|
|
3630
3891
|
maxDurationMs: schema.number().optional(),
|
|
3892
|
+
maxCostUsd: schema.number().optional(),
|
|
3631
3893
|
successCriteria: schema.string().optional(),
|
|
3632
3894
|
constraints: schema.string().optional(),
|
|
3633
3895
|
mode: schema.string().optional(),
|
|
@@ -3690,6 +3952,7 @@ function buildAgentTools(
|
|
|
3690
3952
|
maxTurns: schema.number().optional(),
|
|
3691
3953
|
maxTokens: schema.number().optional(),
|
|
3692
3954
|
maxDurationMs: schema.number().optional(),
|
|
3955
|
+
maxCostUsd: schema.number().optional(),
|
|
3693
3956
|
successCriteria: schema.string().optional(),
|
|
3694
3957
|
constraints: schema.string().optional(),
|
|
3695
3958
|
mode: schema.string().optional(),
|
|
@@ -3956,6 +4219,121 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3956
4219
|
cwd: pluginOptions.cwd || directory,
|
|
3957
4220
|
})
|
|
3958
4221
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
4222
|
+
const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents)
|
|
4223
|
+
const agentGoalAuthority = pluginOptions.agentGoalAuthority === "status" ? "status" : "full"
|
|
4224
|
+
// Opt-out for deployments that deliberately drive execution from a planning
|
|
4225
|
+
// agent. Defaults to false: unattended work must not escape Plan mode.
|
|
4226
|
+
const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true
|
|
4227
|
+
|
|
4228
|
+
// Opt-in: mirrors live goal status into the OpenCode session title, which the
|
|
4229
|
+
// TUI renders persistently. Off by default because it overwrites a
|
|
4230
|
+
// user-visible field.
|
|
4231
|
+
const sessionTitleStatus = pluginOptions.sessionTitleStatus === true
|
|
4232
|
+
|
|
4233
|
+
// Title updates are cosmetic: every path swallows errors after logging at
|
|
4234
|
+
// debug level so a failure can never interrupt the goal loop.
|
|
4235
|
+
const syncSessionTitle = async (sessionID) => {
|
|
4236
|
+
if (!sessionTitleStatus || !sessionID) return
|
|
4237
|
+
const goal = goalStates.get(sessionID)
|
|
4238
|
+
let title
|
|
4239
|
+
if (goal) {
|
|
4240
|
+
title = buildSessionTitle(goal)
|
|
4241
|
+
} else {
|
|
4242
|
+
// No live goal. Completion archives the goal, so without this branch
|
|
4243
|
+
// the last "running" line would stay on the session until /goal clear.
|
|
4244
|
+
// Only rewrite a title this process already owns, and only for an
|
|
4245
|
+
// achieved result; clear still restores the captured original.
|
|
4246
|
+
const result = lastGoalResults.get(sessionID)
|
|
4247
|
+
if (!currentRuntime().appliedTitles.has(sessionID) || result?.state !== "achieved") return
|
|
4248
|
+
title = buildCompletedSessionTitle(result)
|
|
4249
|
+
}
|
|
4250
|
+
if (currentRuntime().appliedTitles.get(sessionID) === title) return
|
|
4251
|
+
try {
|
|
4252
|
+
if (!currentRuntime().sessionTitles.has(sessionID)) {
|
|
4253
|
+
const session = await sessionApi.get(sessionID)
|
|
4254
|
+
const existing = typeof session?.title === "string" ? session.title : ""
|
|
4255
|
+
// A status line left behind by a previous process is not the user's
|
|
4256
|
+
// title; capture empty so clear leaves the host's title alone rather
|
|
4257
|
+
// than restoring stale goal status.
|
|
4258
|
+
currentRuntime().sessionTitles.set(
|
|
4259
|
+
sessionID,
|
|
4260
|
+
looksLikePluginSessionTitle(existing) ? "" : existing,
|
|
4261
|
+
)
|
|
4262
|
+
}
|
|
4263
|
+
await sessionApi.update(sessionID, { title })
|
|
4264
|
+
currentRuntime().appliedTitles.set(sessionID, title)
|
|
4265
|
+
} catch (error) {
|
|
4266
|
+
await logPluginDebug(client, "Failed to update session title", error)
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
const restoreSessionTitle = async (sessionID) => {
|
|
4271
|
+
if (!sessionTitleStatus || !sessionID) return
|
|
4272
|
+
const runtime = currentRuntime()
|
|
4273
|
+
if (!runtime.sessionTitles.has(sessionID)) return
|
|
4274
|
+
const original = runtime.sessionTitles.get(sessionID)
|
|
4275
|
+
runtime.sessionTitles.delete(sessionID)
|
|
4276
|
+
runtime.appliedTitles.delete(sessionID)
|
|
4277
|
+
// Empty means there was nothing genuine to restore (no title, or the
|
|
4278
|
+
// session only carried a status line from a previous process).
|
|
4279
|
+
if (!original) return
|
|
4280
|
+
try {
|
|
4281
|
+
await sessionApi.update(sessionID, { title: original })
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
await logPluginDebug(client, "Failed to restore session title", error)
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
// The restricted agent currently driving this session, or "" when execution
|
|
4288
|
+
// is permitted. Reads the execution context the host reports through
|
|
4289
|
+
// `chat.message`, `chat.params`, and `session.updated`.
|
|
4290
|
+
// Resolve the agent driving a session, preferring the execution context the
|
|
4291
|
+
// host reports through `chat.message` / `chat.params` / `session.updated`.
|
|
4292
|
+
//
|
|
4293
|
+
// That context is empty for the first command in a session: OpenCode runs
|
|
4294
|
+
// `command.execute.before` before any of those signals fire. Relying on it
|
|
4295
|
+
// alone made the restriction fail open exactly where it matters most — a
|
|
4296
|
+
// freshly opened session in Plan mode — so fall back to the session record,
|
|
4297
|
+
// which carries the selected agent from the moment the user picks it.
|
|
4298
|
+
const resolveSessionAgent = async (sessionID) => {
|
|
4299
|
+
if (!sessionID) return ""
|
|
4300
|
+
const cached = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
|
|
4301
|
+
if (typeof cached === "string" && cached.trim()) return cached.trim()
|
|
4302
|
+
try {
|
|
4303
|
+
const session = await sessionApi.get(sessionID)
|
|
4304
|
+
const agent = typeof session?.agent === "string" ? session.agent.trim() : ""
|
|
4305
|
+
// Remember it so later hooks in the same turn do not re-fetch. `replace`
|
|
4306
|
+
// is intentionally false: this must not clobber a richer context (model,
|
|
4307
|
+
// variant) that a host signal may already have recorded.
|
|
4308
|
+
if (agent) rememberSessionExecutionContext(sessionID, { agent })
|
|
4309
|
+
return agent
|
|
4310
|
+
} catch (error) {
|
|
4311
|
+
// Hosts that do not expose the agent fail open, matching the behavior
|
|
4312
|
+
// before the restriction existed.
|
|
4313
|
+
await logPluginDebug(client, "Failed to resolve the session agent", error)
|
|
4314
|
+
return ""
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
const restrictedAgentFor = async (sessionID) => {
|
|
4319
|
+
if (allowGoalExecutionFromPlan) return ""
|
|
4320
|
+
const agent = await resolveSessionAgent(sessionID)
|
|
4321
|
+
return isRestrictedAgent(agent, restrictedAgents) ? agent : ""
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
// Record a newly created goal as held rather than active. Mirrors the idle
|
|
4325
|
+
// guard's stop reason so `/goal status` reads the same either way.
|
|
4326
|
+
const holdGoalForRestrictedAgent = (goal, agent) => {
|
|
4327
|
+
const label = isPlanAgent(agent) ? "Plan" : agent
|
|
4328
|
+
goal.stopped = true
|
|
4329
|
+
goal.stopReason = restrictedAgentStopReason(agent)
|
|
4330
|
+
goal.lastStatus =
|
|
4331
|
+
`Goal recorded but held: the ${label} agent is planning-only. ` +
|
|
4332
|
+
`Switch to an executing agent, then run /${commandName} resume to start work.`
|
|
4333
|
+
pauseGoalClock(goal)
|
|
4334
|
+
pushHistory(goal, "paused", `Created while the ${label} agent was active; held until an executing agent resumes it.`)
|
|
4335
|
+
return label
|
|
4336
|
+
}
|
|
3959
4337
|
|
|
3960
4338
|
// Each session owns an independent snapshot, ledger, write chain, and
|
|
3961
4339
|
// lifetime lease. A project can therefore host any number of unrelated goal
|
|
@@ -4238,6 +4616,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4238
4616
|
auditMessagesEnabled,
|
|
4239
4617
|
announceLifecycle,
|
|
4240
4618
|
commandName,
|
|
4619
|
+
agentGoalAuthority,
|
|
4241
4620
|
})
|
|
4242
4621
|
|
|
4243
4622
|
const abortAcceptedContinuation = async (sessionID) => {
|
|
@@ -4481,12 +4860,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4481
4860
|
|
|
4482
4861
|
if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
|
|
4483
4862
|
|
|
4484
|
-
const
|
|
4485
|
-
if (
|
|
4863
|
+
const activeRestrictedAgent = await restrictedAgentFor(sessionID)
|
|
4864
|
+
if (activeRestrictedAgent) {
|
|
4865
|
+
const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
|
|
4486
4866
|
await pauseActiveGoal(sessionID, {
|
|
4487
|
-
stopReason:
|
|
4488
|
-
status:
|
|
4489
|
-
history:
|
|
4867
|
+
stopReason: restrictedAgentStopReason(activeRestrictedAgent),
|
|
4868
|
+
status: `Auto-continue paused because the active agent switched to ${label}.`,
|
|
4869
|
+
history: `Paused before auto-continue because the active session agent switched to ${label}.`,
|
|
4490
4870
|
})
|
|
4491
4871
|
return null
|
|
4492
4872
|
}
|
|
@@ -4710,6 +5090,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4710
5090
|
// mutate it in place just as command.execute.before does.
|
|
4711
5091
|
message.parts.splice(0, message.parts.length, commandPart)
|
|
4712
5092
|
}
|
|
5093
|
+
// A goal created by the first command of a fresh session could not
|
|
5094
|
+
// know the active agent at creation time (command.execute.before runs
|
|
5095
|
+
// before any chat hook and the Session record carries no agent). The
|
|
5096
|
+
// routed turn does carry it: re-evaluate the planning-only restriction
|
|
5097
|
+
// and hold the goal before the model is told to start working.
|
|
5098
|
+
if (commandTurn.startedGoal && commandTurn.attachmentError !== true) {
|
|
5099
|
+
const startedGoal = goalStates.get(sessionID)
|
|
5100
|
+
const startedByThisCommand =
|
|
5101
|
+
Boolean(startedGoal) &&
|
|
5102
|
+
!startedGoal.stopped &&
|
|
5103
|
+
startedGoal.goalId === commandTurn.startedGoal.goalId &&
|
|
5104
|
+
startedGoal.runId === commandTurn.startedGoal.runId
|
|
5105
|
+
const lateRestrictedAgent = startedByThisCommand ? await restrictedAgentFor(sessionID) : ""
|
|
5106
|
+
if (lateRestrictedAgent) {
|
|
5107
|
+
const heldLabel = holdGoalForRestrictedAgent(startedGoal, lateRestrictedAgent)
|
|
5108
|
+
await persist(sessionID)
|
|
5109
|
+
announceLifecycle(sessionID, `Goal recorded but held while ${heldLabel} is active.`, {
|
|
5110
|
+
goal: startedGoal,
|
|
5111
|
+
transition: "paused",
|
|
5112
|
+
expectedState: "paused",
|
|
5113
|
+
})
|
|
5114
|
+
const commandPart = pluginMarkedTextPart(message, "command")
|
|
5115
|
+
const routedText = frameControlCommandText(
|
|
5116
|
+
buildGoalCommandNotice(startedGoal, { heldLabel, commandName }),
|
|
5117
|
+
)
|
|
5118
|
+
commandPart.text = routedText
|
|
5119
|
+
commandTurn.policy = "control"
|
|
5120
|
+
commandTurn.textDigest = createHash("sha256").update(routedText).digest("hex")
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
4713
5123
|
runtime.activeCommandTurns.set(sessionID, {
|
|
4714
5124
|
...commandTurn,
|
|
4715
5125
|
messageID: currentMessageID,
|
|
@@ -4873,6 +5283,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4873
5283
|
requireCurrent: false,
|
|
4874
5284
|
})
|
|
4875
5285
|
}
|
|
5286
|
+
// Hand the session title back to the user now that no goal owns it.
|
|
5287
|
+
if (clearStillCurrent) await restoreSessionTitle(sessionID)
|
|
4876
5288
|
replaceCommandOutputText(
|
|
4877
5289
|
output,
|
|
4878
5290
|
!clearStillCurrent
|
|
@@ -5240,6 +5652,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5240
5652
|
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
|
|
5241
5653
|
)
|
|
5242
5654
|
|
|
5655
|
+
// A goal set while a planning-only agent is active is recorded but held,
|
|
5656
|
+
// so the objective and its budget survive the mode switch. Without this
|
|
5657
|
+
// the goal is created live and the routed command text tells the model to
|
|
5658
|
+
// start working; the idle guard only catches it on the *next* idle.
|
|
5659
|
+
const creationRestrictedAgent = await restrictedAgentFor(sessionID)
|
|
5660
|
+
if (creationRestrictedAgent) {
|
|
5661
|
+
holdGoalForRestrictedAgent(goal, creationRestrictedAgent)
|
|
5662
|
+
}
|
|
5663
|
+
|
|
5243
5664
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
5244
5665
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
5245
5666
|
// goal and add another. Clear any ordered-sequence flag so the new
|
|
@@ -5251,39 +5672,38 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5251
5672
|
registerSessionGoal(goal)
|
|
5252
5673
|
focusGoal(sessionID, goal)
|
|
5253
5674
|
await persist(sessionID)
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
5280
|
-
goal.options.maxDurationMs / 1000,
|
|
5281
|
-
)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
|
|
5282
|
-
]
|
|
5283
|
-
.filter((line) => line !== null)
|
|
5284
|
-
.join("\n"),
|
|
5285
|
-
{ preserveFiles: true, startsWork: true },
|
|
5675
|
+
// The agent is often unknown here: OpenCode runs command.execute.before
|
|
5676
|
+
// before any chat hook for the turn and its Session record carries no
|
|
5677
|
+
// agent. Remember which goal this command started so chat.message, which
|
|
5678
|
+
// does receive the agent, can still hold it (see that hook).
|
|
5679
|
+
const creationCommandTurn = currentRuntime().commandOutputs.get(output)
|
|
5680
|
+
if (creationCommandTurn && !creationRestrictedAgent) {
|
|
5681
|
+
creationCommandTurn.startedGoal = { goalId: goal.goalId, runId: goal.runId }
|
|
5682
|
+
}
|
|
5683
|
+
const heldLabel = creationRestrictedAgent
|
|
5684
|
+
? isPlanAgent(creationRestrictedAgent)
|
|
5685
|
+
? "Plan"
|
|
5686
|
+
: creationRestrictedAgent
|
|
5687
|
+
: ""
|
|
5688
|
+
announceLifecycle(
|
|
5689
|
+
sessionID,
|
|
5690
|
+
heldLabel
|
|
5691
|
+
? `Goal recorded but held while ${heldLabel} is active.`
|
|
5692
|
+
: replacedGoal
|
|
5693
|
+
? "Goal replaced and active."
|
|
5694
|
+
: "Goal active.",
|
|
5695
|
+
{
|
|
5696
|
+
goal,
|
|
5697
|
+
transition: heldLabel ? "paused" : replacedGoal ? "replaced-active" : "active",
|
|
5698
|
+
expectedState: heldLabel ? "paused" : "active",
|
|
5699
|
+
},
|
|
5286
5700
|
)
|
|
5701
|
+
replaceCommandOutputText(output, buildGoalCommandNotice(goal, { heldLabel, replacedGoal, commandName }), {
|
|
5702
|
+
preserveFiles: true,
|
|
5703
|
+
// A held goal is a control turn, not a work turn: `startsWork: false`
|
|
5704
|
+
// routes it through the read-only command framing.
|
|
5705
|
+
startsWork: !heldLabel,
|
|
5706
|
+
})
|
|
5287
5707
|
},
|
|
5288
5708
|
|
|
5289
5709
|
event: async ({ event }) => {
|
|
@@ -6471,6 +6891,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
6471
6891
|
|
|
6472
6892
|
// register_command toggle: when disabled, the plugin does not own
|
|
6473
6893
|
// a slash command and only the event/transform/compaction hooks remain.
|
|
6894
|
+
// Session-title indicator: rather than threading a sync call through every
|
|
6895
|
+
// state-mutating site (a missed one shows the user a stale status), wrap the
|
|
6896
|
+
// two hooks that gate all state change. The sync no-ops when the rendered
|
|
6897
|
+
// title is unchanged, and runs in `finally` so the displayed status matches
|
|
6898
|
+
// the state actually reached even if a hook throws.
|
|
6899
|
+
if (sessionTitleStatus) {
|
|
6900
|
+
for (const hookName of ["command.execute.before", "event"]) {
|
|
6901
|
+
const original = hooks[hookName]
|
|
6902
|
+
if (typeof original !== "function") continue
|
|
6903
|
+
hooks[hookName] = async (...args) => {
|
|
6904
|
+
try {
|
|
6905
|
+
return await original(...args)
|
|
6906
|
+
} finally {
|
|
6907
|
+
let titleSessionID = ""
|
|
6908
|
+
if (hookName === "event") {
|
|
6909
|
+
// `message.updated` streams many times per assistant turn. Awaiting
|
|
6910
|
+
// a title sync on each would put an API round-trip in the streaming
|
|
6911
|
+
// path for a cosmetic update; idle, compaction, and interruption
|
|
6912
|
+
// events already cover every state the indicator renders.
|
|
6913
|
+
if (args[0]?.event?.type !== "message.updated") {
|
|
6914
|
+
titleSessionID = getSessionID(args[0]?.event)
|
|
6915
|
+
}
|
|
6916
|
+
} else {
|
|
6917
|
+
titleSessionID = args[0]?.sessionID
|
|
6918
|
+
}
|
|
6919
|
+
await syncSessionTitle(titleSessionID)
|
|
6920
|
+
}
|
|
6921
|
+
}
|
|
6922
|
+
}
|
|
6923
|
+
}
|
|
6924
|
+
|
|
6474
6925
|
if (!registerCommand) {
|
|
6475
6926
|
delete hooks["command.execute.before"]
|
|
6476
6927
|
}
|
|
@@ -6607,6 +7058,15 @@ export const testInternals = {
|
|
|
6607
7058
|
isIdleEvent,
|
|
6608
7059
|
isPluginCommandMessage,
|
|
6609
7060
|
isPluginContinuationMessage,
|
|
7061
|
+
isPlanAgent,
|
|
7062
|
+
buildSessionTitle,
|
|
7063
|
+
buildCompletedSessionTitle,
|
|
7064
|
+
formatCompactDuration,
|
|
7065
|
+
formatCompactTokens,
|
|
7066
|
+
goalStatusIcon,
|
|
7067
|
+
looksLikePluginSessionTitle,
|
|
7068
|
+
isRestrictedAgent,
|
|
7069
|
+
normalizeRestrictedAgents,
|
|
6610
7070
|
isPluginGeneratedMessage,
|
|
6611
7071
|
legacyStateFilePaths,
|
|
6612
7072
|
messageHasToolCall,
|
|
@@ -6626,5 +7086,6 @@ export const testInternals = {
|
|
|
6626
7086
|
resolveStateFilePath,
|
|
6627
7087
|
runtimeSessionDiagnostics,
|
|
6628
7088
|
stopReason,
|
|
7089
|
+
costCapFor,
|
|
6629
7090
|
xdgStateFilePath,
|
|
6630
7091
|
}
|