opencode-goal-plugin 0.8.2 → 0.9.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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 — 2026-08-29
6
+
7
+ - Add plan-mode safety: a goal set while a planning-only agent is active is recorded but held instead of starting, with stop reason `plan agent active`, its budget preserved, and a read-only control turn that tells the model not to begin work. Auto-continue already paused when the session switched to Plan; this closes the creation path, where the goal previously started and ran the loop before any idle occurred. The active agent is read from the host's execution context with a fallback to the session record, which matters because `command.execute.before` runs before `chat.message`/`chat.params` — without the fallback the restriction failed open on the first command in a session. Configurable via `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`); the default-on behavior is pinned by the mutation contract. Note the restriction stops the goal loop, not the single routed command turn, which OpenCode does not fully intercept.
8
+ - Add an opt-in session-title status indicator (`sessionTitleStatus`), mirroring live goal status — state icon, objective, turns, elapsed, and tokens — into the OpenCode session title so unattended runs show a continuous heartbeat. The original title is captured before the first overwrite and restored by `/goal clear`; unchanged renders skip the API call, and update failures are logged at debug level without interrupting the goal loop. The indicator refreshes on commands and on idle/compaction/interruption events but never on the `message.updated` events that stream during a turn, keeping API round-trips out of the response path. Status lines written by a previous process are recognized as the plugin's own, so a restart followed by `/goal clear` cannot promote stale goal status to the session's permanent title.
9
+ - Verify both features against a live OpenCode 1.18.25 TUI, not only unit and contract tests: a goal set under Plan records `stopped: true` with zero auto-continues, and the status indicator renders in the session title.
10
+ - Document OpenCode 2 status explicitly: unsupported and untested, with the existing dual-shape session adapter noted and a concrete checklist of what a supported claim would require. Records that this plugin is server-only, so its configuration lives entirely in `opencode.json` on any OpenCode line.
11
+
5
12
  ## 0.8.2 — 2026-08-21
6
13
 
7
14
  - Harden the post-compaction goal continuation guard introduced in
package/README.md CHANGED
@@ -31,13 +31,14 @@ This project is independently implemented for OpenCode. Product names used elsew
31
31
  | Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
32
32
  | Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
33
33
  | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
34
+ | OpenCode 2 | Not supported and not yet tested; the peer/engine pin is `>=1.17.15 <2`. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) |
34
35
 
35
36
  See the [compatibility policy](docs/compatibility.md) for the supported public
36
37
  surface and versioning expectations.
37
38
 
38
39
  ### OpenCode version compatibility
39
40
 
40
- Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
41
+ Tested against real OpenCode 1.17.15 and 1.18.25 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
41
42
 
42
43
  | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Historical custom-command presentation (v0.6.6) |
43
44
  |---|---|---|---|---|---|
@@ -45,6 +46,7 @@ Tested against real OpenCode 1.17.15 processes with live provider credentials an
45
46
  | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
46
47
  | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
47
48
  | 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
49
+ | 1.18.25 | opencode (`nemotron-3.5-lightning-free`) | ✅ | ✅ Held correctly under the Plan agent (`stopped: true`, zero auto-continues) | ✅ Clean `[goal:evidence]` + `[goal:complete]` | ⚠️ Not displayed; command text routed to model |
48
50
 
49
51
  `/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
50
52
 
@@ -401,6 +403,62 @@ await GoalPlugin(
401
403
 
402
404
  `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied.
403
405
 
406
+ ## Status indicator
407
+
408
+ Unattended runs are easier to trust when you can see the goal is still alive. Set `sessionTitleStatus: true` and the plugin mirrors live goal status into the OpenCode session title, which the TUI renders persistently:
409
+
410
+ ```
411
+ ▶ ship the release · 3/10 · 2m · 45k/200k
412
+ ```
413
+
414
+ Status icon, objective, auto-continues used / limit, elapsed time, and context tokens / budget. The icon distinguishes running (`▶`), paused (`⏸`), and blocked (`⛔`) — blocked outranks paused because it needs you, not just a resume. A paused goal freezes its elapsed clock rather than running on.
415
+
416
+ ```json
417
+ {
418
+ "plugin": [
419
+ ["opencode-goal-plugin", { "sessionTitleStatus": true }]
420
+ ]
421
+ }
422
+ ```
423
+
424
+ The option is **off by default** because it overwrites a user-visible field. When enabled, the session's original title is captured before the first overwrite and restored by `/goal clear`. A render identical to the last one skips the API call, so `/goal status` and other read-only commands cost nothing. Title updates are cosmetic: a failure is logged at debug level and never interrupts the goal loop.
425
+
426
+ The indicator refreshes on goal commands and on idle, compaction, and interruption events — **not** on the `message.updated` events that stream during an assistant turn. Streaming refreshes would put an API round-trip in the response path for a cosmetic update, and idle is the cadence a human actually reads the indicator at.
427
+
428
+ The captured original title lives in memory only, so a hard process kill leaves the last status line on the session. The plugin recognizes its own status lines and will not mistake one for your title, so `/goal clear` after a restart leaves the host's title alone rather than restoring stale goal status — but it cannot recover the title the session had before the goal started. Rename the session if you want it back.
429
+
430
+ This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step.
431
+
432
+ ## Plan-mode safety
433
+
434
+ A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default:
435
+
436
+ - A goal set while `plan` is active is **recorded but held**, with stop reason `plan agent active`. The objective and its budget survive, so nothing is lost — the goal simply does not start.
437
+ - The routed confirmation text for a held goal **omits the "start working" instruction** and is sent as a read-only control turn. This matters because command text reaches the model as a normal turn on current OpenCode builds (see [Limitations](#limitations)).
438
+ - Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop.
439
+ - Continuations retain the agent that started the goal, so the loop cannot drift into a different agent.
440
+
441
+ The active agent is read from the execution context the host reports, falling back to the session record. That fallback matters: OpenCode runs `command.execute.before` before any `chat.message`/`chat.params` for the turn, so the context is empty for the first command in a session — the exact case a freshly opened Plan-mode session hits.
442
+
443
+ **What this does and does not prevent.** The restriction stops the *goal loop*: a held goal sends zero auto-continues, so no unattended work happens. It cannot stop a model from acting on the single routed command turn, because OpenCode's `command.execute.before` does not fully intercept command text (see [Limitations](#limitations)). A held goal's routed text explicitly tells the model not to begin work and is sent as a read-only control turn, but a non-compliant model may still act on that one turn. Verified against OpenCode 1.18.25: a goal set under Plan records `stopped: true`, `stopReason: plan agent active`, and `turnCount: 0`.
444
+
445
+ Run `/goal resume` after switching back to an executing agent to start the work.
446
+
447
+ | Option | Default | Controls |
448
+ |---|---|---|
449
+ | `restrictedAgents` | `["plan"]` | Agent names treated as planning-only (case-insensitive). Pass `[]` to release the restriction. |
450
+ | `allowGoalExecutionFromPlan` | `false` | Set `true` to allow goal creation and auto-continue while a restricted agent is active. |
451
+
452
+ ```json
453
+ {
454
+ "plugin": [
455
+ ["opencode-goal-plugin", { "restrictedAgents": ["plan", "review"] }]
456
+ ]
457
+ }
458
+ ```
459
+
460
+ The restriction being on by default is pinned by the mutation contract: hardcoding `allowGoalExecutionFromPlan` to `true` fails the suite.
461
+
404
462
  ## Prompt safety
405
463
 
406
464
  The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
@@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool
81
81
  blocking, and parent-correlated lifecycle suppression. The system transform
82
82
  remains registered as additional protection for hosts that support it.
83
83
 
84
+ ## OpenCode 2
85
+
86
+ **Status: not supported, and not yet tested.**
87
+
88
+ The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as
89
+ `>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made
90
+ without a verified run behind it, and the project has not yet exercised the
91
+ plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than
92
+ as known-broken.
93
+
94
+ ### What already exists in this direction
95
+
96
+ - `createOpenCodeSessionApi` speaks both the legacy generated-client shape
97
+ (`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`),
98
+ selected per operation and remembered after the first success. The
99
+ `sdkShape: "flat"` option pins the flattened shape for embedded clients.
100
+ - Only read-only operations are ever replayed against the alternate shape, so a
101
+ shape probe can never duplicate a mutating call. This invariant is pinned by
102
+ the mutation contract.
103
+
104
+ ### What a supported v2 claim would require
105
+
106
+ Before the pin is widened, all of the following need to pass against a real
107
+ OpenCode 2 build, not a mock:
108
+
109
+ 1. Plugin load and hook registration through the v2 plugin entrypoint.
110
+ 2. `command.execute.before`, `event`, `experimental.chat.system.transform`,
111
+ `experimental.session.compacting`, and `experimental.compaction.autocontinue`
112
+ firing with the shapes the plugin expects.
113
+ 3. The execution-context signals (`chat.message`, `chat.params`,
114
+ `session.updated`) still reporting the active agent, which the planning-only
115
+ restriction depends on.
116
+ 4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`,
117
+ `abort`) under whichever argument shape v2 ships.
118
+ 5. Goal-specific compaction context and recovery of running child sessions after
119
+ a plugin restart, which are the areas most likely to differ.
120
+
121
+ ### Configuration
122
+
123
+ This plugin is **server-only**: `package.json` exports the root and
124
+ `opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its
125
+ configuration therefore lives entirely in `opencode.json` (the `plugin` and
126
+ `command` keys) on any OpenCode line.
127
+
128
+ Plugins that *do* ship a TUI component are registered in a second file whose
129
+ location differs between OpenCode lines, and those formats must not be mixed.
130
+ That distinction does not apply here — including for the
131
+ [status indicator](../README.md#status-indicator), which reaches the TUI through
132
+ the session title rather than through a TUI plugin.
133
+
84
134
  ## Versioning
85
135
 
86
136
  Semantic-versioning intent is:
package/index.d.ts CHANGED
@@ -311,6 +311,34 @@ export interface GoalPluginOptions {
311
311
  /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
312
312
  registerAgents?: boolean
313
313
 
314
+ /**
315
+ * Mirror live goal status into the OpenCode session title, which the TUI
316
+ * renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`),
317
+ * giving unattended runs a continuous heartbeat without a TUI plugin.
318
+ *
319
+ * The session's original title is captured before the first overwrite and
320
+ * restored by `/goal clear`. Title updates are cosmetic: a failure is logged
321
+ * at debug level and never interrupts the goal loop.
322
+ * @default false
323
+ */
324
+ sessionTitleStatus?: boolean
325
+
326
+ /**
327
+ * Agent names treated as planning-only. A goal created while one of these
328
+ * agents is active is recorded but held paused instead of starting, and
329
+ * auto-continue stays suppressed while one is active. Matching is
330
+ * case-insensitive. Pass `[]` to release the restriction entirely.
331
+ * @default ["plan"]
332
+ */
333
+ restrictedAgents?: string[]
334
+
335
+ /**
336
+ * Opt out of the planning-only restriction, allowing goals to be created and
337
+ * auto-continued while a {@link restrictedAgents} agent is active.
338
+ * @default false
339
+ */
340
+ allowGoalExecutionFromPlan?: boolean
341
+
314
342
  /** Name of the native primary goal agent. @default "goal" */
315
343
  goalAgentName?: string
316
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -111,6 +111,11 @@ function createRuntimeState() {
111
111
  seenIdleEventIDs: new Set(),
112
112
  sessionStatuses: new Map(),
113
113
  sessionExecutionContexts: new Map(),
114
+ // Session-title indicator: the user's own title, captured before the plugin
115
+ // first overwrites it, and the last title the plugin wrote (so an unchanged
116
+ // render skips the API call).
117
+ sessionTitles: new Map(),
118
+ appliedTitles: new Map(),
114
119
  pendingCommandTurns: new Map(),
115
120
  activeCommandTurns: new Map(),
116
121
  commandOutputs: new WeakMap(),
@@ -396,8 +401,95 @@ function continuationContextInput(goal) {
396
401
  return context ? { ...context } : {}
397
402
  }
398
403
 
404
+ // Planning-only agents must never be driven into execution by the goal loop.
405
+ // `plan` is OpenCode's built-in read-only agent; `restrictedAgents` lets a
406
+ // deployment name others (for example a review-only agent).
407
+ const DEFAULT_RESTRICTED_AGENTS = ["plan"]
408
+
409
+ function normalizeRestrictedAgents(value) {
410
+ // Anything that is not an array (including undefined) keeps the safe default;
411
+ // an explicit empty array is a deliberate opt-out.
412
+ if (!Array.isArray(value)) return [...DEFAULT_RESTRICTED_AGENTS]
413
+ const names = value
414
+ .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
415
+ .filter(Boolean)
416
+ return [...new Set(names)]
417
+ }
418
+
419
+ function isRestrictedAgent(agent, restrictedAgents = DEFAULT_RESTRICTED_AGENTS) {
420
+ if (typeof agent !== "string") return false
421
+ const name = agent.trim().toLowerCase()
422
+ if (!name) return false
423
+ return restrictedAgents.includes(name)
424
+ }
425
+
399
426
  function isPlanAgent(agent) {
400
- return typeof agent === "string" && agent.trim().toLowerCase() === "plan"
427
+ return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS)
428
+ }
429
+
430
+ // Session-title status indicator. OpenCode renders the session title
431
+ // persistently, so mirroring goal progress into it gives unattended runs a
432
+ // continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it
433
+ // overwrites a user-visible field.
434
+ const SESSION_TITLE_OBJECTIVE_LIMIT = 48
435
+ const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"]
436
+
437
+ // The title sits in a narrow column, so every field is abbreviated hard.
438
+ function formatCompactDuration(ms) {
439
+ const totalSeconds = Math.max(0, Math.round(ms / 1000))
440
+ if (totalSeconds < 60) return `${totalSeconds}s`
441
+ const totalMinutes = Math.floor(totalSeconds / 60)
442
+ if (totalMinutes < 60) return `${totalMinutes}m`
443
+ const hours = Math.floor(totalMinutes / 60)
444
+ const minutes = totalMinutes % 60
445
+ return minutes ? `${hours}h${minutes}m` : `${hours}h`
446
+ }
447
+
448
+ function formatCompactTokens(tokens) {
449
+ const value = toNonNegativeInteger(tokens)
450
+ if (value < 1000) return String(value)
451
+ if (value < 1_000_000) {
452
+ const thousands = value / 1000
453
+ return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`
454
+ }
455
+ const millions = value / 1_000_000
456
+ return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m`
457
+ }
458
+
459
+ // Blocked and paused are distinct to a watching human: one needs input, the
460
+ // other just needs a resume.
461
+ function goalStatusIcon(goal) {
462
+ if (goal.blockedReason) return "⛔"
463
+ if (goal.stopped) return "⏸"
464
+ return "▶"
465
+ }
466
+
467
+ // One-line goal status for the session title, e.g.
468
+ // "▶ ship the release · 3/10 · 2m · 45k/200k".
469
+ function buildSessionTitle(goal, now = Date.now()) {
470
+ const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt)
471
+ return [
472
+ `${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
473
+ `${goal.turnCount}/${goal.options.maxTurns}`,
474
+ formatCompactDuration(elapsedMs),
475
+ `${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`,
476
+ ].join(" · ")
477
+ }
478
+
479
+ // Recognize a title this plugin wrote. The captured "original" is what
480
+ // `/goal clear` restores, so capturing one of our own status lines would make
481
+ // clear promote a stale status string to the permanent session title. That is
482
+ // exactly the state a hard process kill leaves behind.
483
+ function looksLikePluginSessionTitle(title) {
484
+ const text = typeof title === "string" ? title.trimStart() : ""
485
+ return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `))
486
+ }
487
+
488
+ // Stop reason for a goal held because a planning-only agent is active. The
489
+ // built-in `plan` case keeps its established wording so persisted state and
490
+ // existing consumers stay stable.
491
+ function restrictedAgentStopReason(agent) {
492
+ return isPlanAgent(agent) ? "plan agent active" : `${String(agent).trim().toLowerCase()} agent active`
401
493
  }
402
494
 
403
495
  function terminalEvent(event) {
@@ -920,6 +1012,8 @@ function clearRuntimeState() {
920
1012
  runtime.seenIdleEventIDs.clear()
921
1013
  runtime.sessionStatuses.clear()
922
1014
  runtime.sessionExecutionContexts.clear()
1015
+ runtime.sessionTitles.clear()
1016
+ runtime.appliedTitles.clear()
923
1017
  runtime.pendingCommandTurns.clear()
924
1018
  runtime.activeCommandTurns.clear()
925
1019
  runtime.ownedPluginMessages.clear()
@@ -2133,6 +2227,26 @@ async function logPluginWarning(client, message) {
2133
2227
  return logPluginMessage(client, "warn", message)
2134
2228
  }
2135
2229
 
2230
+ // Cosmetic failures (session-title updates) log at debug and never fall back to
2231
+ // the console: a title that failed to render must not look like a goal fault.
2232
+ async function logPluginDebug(client, message, error) {
2233
+ if (!client?.app?.log) return
2234
+ try {
2235
+ await client.app.log({
2236
+ body: {
2237
+ service: "opencode-goal-plugin",
2238
+ level: "debug",
2239
+ message,
2240
+ ...(error === undefined
2241
+ ? {}
2242
+ : { extra: { error: error?.message || error?.name || String(error) } }),
2243
+ },
2244
+ })
2245
+ } catch {
2246
+ // Diagnostics must never affect the goal loop.
2247
+ }
2248
+ }
2249
+
2136
2250
  function parseGoalArguments(args, defaults) {
2137
2251
  const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
2138
2252
  const condition = []
@@ -3956,6 +4070,110 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3956
4070
  cwd: pluginOptions.cwd || directory,
3957
4071
  })
3958
4072
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
4073
+ const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents)
4074
+ // Opt-out for deployments that deliberately drive execution from a planning
4075
+ // agent. Defaults to false: unattended work must not escape Plan mode.
4076
+ const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true
4077
+
4078
+ // Opt-in: mirrors live goal status into the OpenCode session title, which the
4079
+ // TUI renders persistently. Off by default because it overwrites a
4080
+ // user-visible field.
4081
+ const sessionTitleStatus = pluginOptions.sessionTitleStatus === true
4082
+
4083
+ // Title updates are cosmetic: every path swallows errors after logging at
4084
+ // debug level so a failure can never interrupt the goal loop.
4085
+ const syncSessionTitle = async (sessionID) => {
4086
+ if (!sessionTitleStatus || !sessionID) return
4087
+ const goal = goalStates.get(sessionID)
4088
+ if (!goal) return
4089
+ const title = buildSessionTitle(goal)
4090
+ if (currentRuntime().appliedTitles.get(sessionID) === title) return
4091
+ try {
4092
+ if (!currentRuntime().sessionTitles.has(sessionID)) {
4093
+ const session = await sessionApi.get(sessionID)
4094
+ const existing = typeof session?.title === "string" ? session.title : ""
4095
+ // A status line left behind by a previous process is not the user's
4096
+ // title; capture empty so clear leaves the host's title alone rather
4097
+ // than restoring stale goal status.
4098
+ currentRuntime().sessionTitles.set(
4099
+ sessionID,
4100
+ looksLikePluginSessionTitle(existing) ? "" : existing,
4101
+ )
4102
+ }
4103
+ await sessionApi.update(sessionID, { title })
4104
+ currentRuntime().appliedTitles.set(sessionID, title)
4105
+ } catch (error) {
4106
+ await logPluginDebug(client, "Failed to update session title", error)
4107
+ }
4108
+ }
4109
+
4110
+ const restoreSessionTitle = async (sessionID) => {
4111
+ if (!sessionTitleStatus || !sessionID) return
4112
+ const runtime = currentRuntime()
4113
+ if (!runtime.sessionTitles.has(sessionID)) return
4114
+ const original = runtime.sessionTitles.get(sessionID)
4115
+ runtime.sessionTitles.delete(sessionID)
4116
+ runtime.appliedTitles.delete(sessionID)
4117
+ // Empty means there was nothing genuine to restore (no title, or the
4118
+ // session only carried a status line from a previous process).
4119
+ if (!original) return
4120
+ try {
4121
+ await sessionApi.update(sessionID, { title: original })
4122
+ } catch (error) {
4123
+ await logPluginDebug(client, "Failed to restore session title", error)
4124
+ }
4125
+ }
4126
+
4127
+ // The restricted agent currently driving this session, or "" when execution
4128
+ // is permitted. Reads the execution context the host reports through
4129
+ // `chat.message`, `chat.params`, and `session.updated`.
4130
+ // Resolve the agent driving a session, preferring the execution context the
4131
+ // host reports through `chat.message` / `chat.params` / `session.updated`.
4132
+ //
4133
+ // That context is empty for the first command in a session: OpenCode runs
4134
+ // `command.execute.before` before any of those signals fire. Relying on it
4135
+ // alone made the restriction fail open exactly where it matters most — a
4136
+ // freshly opened session in Plan mode — so fall back to the session record,
4137
+ // which carries the selected agent from the moment the user picks it.
4138
+ const resolveSessionAgent = async (sessionID) => {
4139
+ if (!sessionID) return ""
4140
+ const cached = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent
4141
+ if (typeof cached === "string" && cached.trim()) return cached.trim()
4142
+ try {
4143
+ const session = await sessionApi.get(sessionID)
4144
+ const agent = typeof session?.agent === "string" ? session.agent.trim() : ""
4145
+ // Remember it so later hooks in the same turn do not re-fetch. `replace`
4146
+ // is intentionally false: this must not clobber a richer context (model,
4147
+ // variant) that a host signal may already have recorded.
4148
+ if (agent) rememberSessionExecutionContext(sessionID, { agent })
4149
+ return agent
4150
+ } catch (error) {
4151
+ // Hosts that do not expose the agent fail open, matching the behavior
4152
+ // before the restriction existed.
4153
+ await logPluginDebug(client, "Failed to resolve the session agent", error)
4154
+ return ""
4155
+ }
4156
+ }
4157
+
4158
+ const restrictedAgentFor = async (sessionID) => {
4159
+ if (allowGoalExecutionFromPlan) return ""
4160
+ const agent = await resolveSessionAgent(sessionID)
4161
+ return isRestrictedAgent(agent, restrictedAgents) ? agent : ""
4162
+ }
4163
+
4164
+ // Record a newly created goal as held rather than active. Mirrors the idle
4165
+ // guard's stop reason so `/goal status` reads the same either way.
4166
+ const holdGoalForRestrictedAgent = (goal, agent) => {
4167
+ const label = isPlanAgent(agent) ? "Plan" : agent
4168
+ goal.stopped = true
4169
+ goal.stopReason = restrictedAgentStopReason(agent)
4170
+ goal.lastStatus =
4171
+ `Goal recorded but held: the ${label} agent is planning-only. ` +
4172
+ `Switch to an executing agent, then run /${commandName} resume to start work.`
4173
+ pauseGoalClock(goal)
4174
+ pushHistory(goal, "paused", `Created while the ${label} agent was active; held until an executing agent resumes it.`)
4175
+ return label
4176
+ }
3959
4177
 
3960
4178
  // Each session owns an independent snapshot, ledger, write chain, and
3961
4179
  // lifetime lease. A project can therefore host any number of unrelated goal
@@ -4481,12 +4699,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4481
4699
 
4482
4700
  if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
4483
4701
 
4484
- const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
4485
- if (isPlanAgent(currentContext?.agent)) {
4702
+ const activeRestrictedAgent = await restrictedAgentFor(sessionID)
4703
+ if (activeRestrictedAgent) {
4704
+ const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
4486
4705
  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.",
4706
+ stopReason: restrictedAgentStopReason(activeRestrictedAgent),
4707
+ status: `Auto-continue paused because the active agent switched to ${label}.`,
4708
+ history: `Paused before auto-continue because the active session agent switched to ${label}.`,
4490
4709
  })
4491
4710
  return null
4492
4711
  }
@@ -4873,6 +5092,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4873
5092
  requireCurrent: false,
4874
5093
  })
4875
5094
  }
5095
+ // Hand the session title back to the user now that no goal owns it.
5096
+ if (clearStillCurrent) await restoreSessionTitle(sessionID)
4876
5097
  replaceCommandOutputText(
4877
5098
  output,
4878
5099
  !clearStillCurrent
@@ -5240,6 +5461,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5240
5461
  `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
5241
5462
  )
5242
5463
 
5464
+ // A goal set while a planning-only agent is active is recorded but held,
5465
+ // so the objective and its budget survive the mode switch. Without this
5466
+ // the goal is created live and the routed command text tells the model to
5467
+ // start working; the idle guard only catches it on the *next* idle.
5468
+ const creationRestrictedAgent = await restrictedAgentFor(sessionID)
5469
+ if (creationRestrictedAgent) {
5470
+ holdGoalForRestrictedAgent(goal, creationRestrictedAgent)
5471
+ }
5472
+
5243
5473
  // Replace the focused goal (cleanupGoal discards it); backgrounded goals
5244
5474
  // for this session are preserved. Use `/goal add` to keep the current
5245
5475
  // goal and add another. Clear any ordered-sequence flag so the new
@@ -5251,11 +5481,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5251
5481
  registerSessionGoal(goal)
5252
5482
  focusGoal(sessionID, goal)
5253
5483
  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
- })
5484
+ const heldLabel = creationRestrictedAgent
5485
+ ? isPlanAgent(creationRestrictedAgent)
5486
+ ? "Plan"
5487
+ : creationRestrictedAgent
5488
+ : ""
5489
+ announceLifecycle(
5490
+ sessionID,
5491
+ heldLabel
5492
+ ? `Goal recorded but held while ${heldLabel} is active.`
5493
+ : replacedGoal
5494
+ ? "Goal replaced and active."
5495
+ : "Goal active.",
5496
+ {
5497
+ goal,
5498
+ transition: heldLabel ? "paused" : replacedGoal ? "replaced-active" : "active",
5499
+ expectedState: heldLabel ? "paused" : "active",
5500
+ },
5501
+ )
5259
5502
  replaceCommandOutputText(
5260
5503
  output,
5261
5504
  [
@@ -5266,14 +5509,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5266
5509
  "",
5267
5510
  ]
5268
5511
  : []),
5269
- `New active goal: ${goal.condition}`,
5512
+ heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
5270
5513
  goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
5271
5514
  goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
5272
5515
  goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
5273
5516
  "",
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]`.",
5517
+ // A held goal must not be told to start working. Command text reaches
5518
+ // the model as a normal turn on current OpenCode builds, so this line
5519
+ // would be the escape the plan guard exists to prevent.
5520
+ ...(heldLabel
5521
+ ? [
5522
+ `The ${heldLabel} agent is planning-only, so this goal is not running.`,
5523
+ "Do not begin work on it now. Continue planning only.",
5524
+ `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`,
5525
+ ]
5526
+ : [
5527
+ "Start working toward this goal now.",
5528
+ "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.",
5529
+ "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.",
5530
+ ]),
5277
5531
  `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
5278
5532
  "",
5279
5533
  `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
@@ -5282,7 +5536,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5282
5536
  ]
5283
5537
  .filter((line) => line !== null)
5284
5538
  .join("\n"),
5285
- { preserveFiles: true, startsWork: true },
5539
+ // A held goal is a control turn, not a work turn: `startsWork: false`
5540
+ // routes it through the read-only command framing.
5541
+ { preserveFiles: true, startsWork: !heldLabel },
5286
5542
  )
5287
5543
  },
5288
5544
 
@@ -6471,6 +6727,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6471
6727
 
6472
6728
  // register_command toggle: when disabled, the plugin does not own
6473
6729
  // a slash command and only the event/transform/compaction hooks remain.
6730
+ // Session-title indicator: rather than threading a sync call through every
6731
+ // state-mutating site (a missed one shows the user a stale status), wrap the
6732
+ // two hooks that gate all state change. The sync no-ops when the rendered
6733
+ // title is unchanged, and runs in `finally` so the displayed status matches
6734
+ // the state actually reached even if a hook throws.
6735
+ if (sessionTitleStatus) {
6736
+ for (const hookName of ["command.execute.before", "event"]) {
6737
+ const original = hooks[hookName]
6738
+ if (typeof original !== "function") continue
6739
+ hooks[hookName] = async (...args) => {
6740
+ try {
6741
+ return await original(...args)
6742
+ } finally {
6743
+ let titleSessionID = ""
6744
+ if (hookName === "event") {
6745
+ // `message.updated` streams many times per assistant turn. Awaiting
6746
+ // a title sync on each would put an API round-trip in the streaming
6747
+ // path for a cosmetic update; idle, compaction, and interruption
6748
+ // events already cover every state the indicator renders.
6749
+ if (args[0]?.event?.type !== "message.updated") {
6750
+ titleSessionID = getSessionID(args[0]?.event)
6751
+ }
6752
+ } else {
6753
+ titleSessionID = args[0]?.sessionID
6754
+ }
6755
+ await syncSessionTitle(titleSessionID)
6756
+ }
6757
+ }
6758
+ }
6759
+ }
6760
+
6474
6761
  if (!registerCommand) {
6475
6762
  delete hooks["command.execute.before"]
6476
6763
  }
@@ -6607,6 +6894,14 @@ export const testInternals = {
6607
6894
  isIdleEvent,
6608
6895
  isPluginCommandMessage,
6609
6896
  isPluginContinuationMessage,
6897
+ isPlanAgent,
6898
+ buildSessionTitle,
6899
+ formatCompactDuration,
6900
+ formatCompactTokens,
6901
+ goalStatusIcon,
6902
+ looksLikePluginSessionTitle,
6903
+ isRestrictedAgent,
6904
+ normalizeRestrictedAgents,
6610
6905
  isPluginGeneratedMessage,
6611
6906
  legacyStateFilePaths,
6612
6907
  messageHasToolCall,