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.
@@ -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 typeof agent === "string" && agent.trim().toLowerCase() === "plan"
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 = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
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
- const next = parts[i + 1]
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 next = parts[i + 1]
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 currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
4485
- if (isPlanAgent(currentContext?.agent)) {
4863
+ const activeRestrictedAgent = await restrictedAgentFor(sessionID)
4864
+ if (activeRestrictedAgent) {
4865
+ const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
4486
4866
  await pauseActiveGoal(sessionID, {
4487
- stopReason: "plan agent active",
4488
- status: "Auto-continue paused because the active agent switched to Plan.",
4489
- history: "Paused before auto-continue because the active session agent switched to Plan.",
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
- announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
5255
- goal,
5256
- transition: replacedGoal ? "replaced-active" : "active",
5257
- expectedState: "active",
5258
- })
5259
- replaceCommandOutputText(
5260
- output,
5261
- [
5262
- ...(replacedGoal
5263
- ? [
5264
- `⚠️ Replacing active goal: "${replacedGoal.condition}"`,
5265
- `Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
5266
- "",
5267
- ]
5268
- : []),
5269
- `New active goal: ${goal.condition}`,
5270
- goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
5271
- goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
5272
- goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
5273
- "",
5274
- "Start working toward this goal now.",
5275
- "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.",
5276
- "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
5277
- `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
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
  }