opencode-goal-plugin 0.8.1 → 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,37 @@
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
+
12
+ ## 0.8.2 — 2026-08-21
13
+
14
+ - Harden the post-compaction goal continuation guard introduced in
15
+ [#58](https://github.com/willytop8/OpenCode-goal-plugin/pull/58). Building on
16
+ the epoch-guard groundwork contributed by
17
+ [@harryzhou2000](https://github.com/harryzhou2000), the stalled-compaction
18
+ circuit breaker no longer misfires on two conditions that OpenCode produces in
19
+ practice:
20
+ - A re-delivered `session.compacted` event no longer counts as a second
21
+ compaction. Real OpenCode compaction events carry only a `sessionID` (no
22
+ identity field), so identity-based deduplication could never fire against a
23
+ live host; a duplicate host delivery could therefore trip the two-strike
24
+ breaker and abort the session. Re-deliveries are now recognized by the
25
+ absence of any message activity since the previous compaction.
26
+ - A `[goal:complete]` or `[goal:blocked]` reported on the assistant turn that
27
+ a compaction retains as its continuation source is now honored instead of
28
+ being suppressed, so a finished goal is archived rather than driven for one
29
+ more redundant continuation.
30
+ - Remove a productive-turn detection branch that inspected message parts on the
31
+ `message.updated` event. That event never carries parts (they arrive on
32
+ `message.part.updated`, which the plugin does not observe), so the check was
33
+ inert; the stalled-compaction breaker resets on assistant output-token
34
+ progress, which a tool-using turn also produces.
35
+
5
36
  ## 0.8.1 — 2026-08-07
6
37
 
7
38
  - Fix goal auto-continue stalling after session compaction: a continuation
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.1",
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",
@@ -40,6 +40,7 @@ function legacyHomeStateFilePath(env = process.env) {
40
40
  return join(homeBase(env), ".opencode-goal-plugin", "state.json")
41
41
  }
42
42
  const MAX_HISTORY_ENTRIES = 20
43
+ const MAX_STALLED_COMPACTIONS = 2
43
44
  // Marks a plugin-synthesized parent wake so the receiving pass knows it is
44
45
  // re-examining an assistant turn that has already been scored.
45
46
  const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake")
@@ -110,6 +111,11 @@ function createRuntimeState() {
110
111
  seenIdleEventIDs: new Set(),
111
112
  sessionStatuses: new Map(),
112
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(),
113
119
  pendingCommandTurns: new Map(),
114
120
  activeCommandTurns: new Map(),
115
121
  commandOutputs: new WeakMap(),
@@ -395,8 +401,95 @@ function continuationContextInput(goal) {
395
401
  return context ? { ...context } : {}
396
402
  }
397
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
+
398
426
  function isPlanAgent(agent) {
399
- 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`
400
493
  }
401
494
 
402
495
  function terminalEvent(event) {
@@ -919,6 +1012,8 @@ function clearRuntimeState() {
919
1012
  runtime.seenIdleEventIDs.clear()
920
1013
  runtime.sessionStatuses.clear()
921
1014
  runtime.sessionExecutionContexts.clear()
1015
+ runtime.sessionTitles.clear()
1016
+ runtime.appliedTitles.clear()
922
1017
  runtime.pendingCommandTurns.clear()
923
1018
  runtime.activeCommandTurns.clear()
924
1019
  runtime.ownedPluginMessages.clear()
@@ -1104,6 +1199,11 @@ function resetGoalBudget(goal) {
1104
1199
  goal.formatFailures = 0
1105
1200
  goal.lastAssistantMessageID = ""
1106
1201
  goal.continuationClaim = null
1202
+ goal.compactionEpoch = 0
1203
+ goal.stalledCompactions = 0
1204
+ goal.lastCompactionEventID = ""
1205
+ goal.messageSeenSinceCompaction = true
1206
+ goal.compactionSourceAssistantMessageID = ""
1107
1207
  goal.skipNextTerminalCheck = false
1108
1208
  goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
1109
1209
  }
@@ -1433,15 +1533,31 @@ function normalizePersistedGoal(rawGoal) {
1433
1533
  stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
1434
1534
  promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
1435
1535
  formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
1536
+ compactionEpoch: toNonNegativeInteger(rawGoal.compactionEpoch),
1537
+ stalledCompactions: toNonNegativeInteger(rawGoal.stalledCompactions),
1538
+ lastCompactionEventID:
1539
+ typeof rawGoal.lastCompactionEventID === "string" &&
1540
+ rawGoal.lastCompactionEventID.length <= MAX_GOAL_META_LENGTH
1541
+ ? rawGoal.lastCompactionEventID
1542
+ : "",
1543
+ messageSeenSinceCompaction: rawGoal.messageSeenSinceCompaction !== false,
1544
+ compactionSourceAssistantMessageID:
1545
+ typeof rawGoal.compactionSourceAssistantMessageID === "string" &&
1546
+ rawGoal.compactionSourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
1547
+ ? rawGoal.compactionSourceAssistantMessageID
1548
+ : "",
1436
1549
  executionContext: normalizeExecutionContext(rawGoal.executionContext),
1437
1550
  continuationClaim:
1438
1551
  isPlainObject(rawGoal.continuationClaim) &&
1439
1552
  typeof rawGoal.continuationClaim.runId === "string" &&
1440
1553
  rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH &&
1554
+ Number.isSafeInteger(rawGoal.continuationClaim.compactionEpoch) &&
1555
+ rawGoal.continuationClaim.compactionEpoch >= 0 &&
1441
1556
  typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" &&
1442
1557
  rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH
1443
1558
  ? {
1444
1559
  runId: rawGoal.continuationClaim.runId,
1560
+ compactionEpoch: rawGoal.continuationClaim.compactionEpoch,
1445
1561
  sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID,
1446
1562
  }
1447
1563
  : null,
@@ -2111,6 +2227,26 @@ async function logPluginWarning(client, message) {
2111
2227
  return logPluginMessage(client, "warn", message)
2112
2228
  }
2113
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
+
2114
2250
  function parseGoalArguments(args, defaults) {
2115
2251
  const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
2116
2252
  const condition = []
@@ -2635,7 +2771,42 @@ function systemBlockContainsGoal(block, goalId) {
2635
2771
  }
2636
2772
 
2637
2773
  function findLatestAssistantMessage(messages) {
2638
- return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
2774
+ return [...(messages || [])]
2775
+ .reverse()
2776
+ .find(
2777
+ (message) =>
2778
+ messageRole(message) === "assistant" && !isCompactionAssistantMessage(message),
2779
+ ) || null
2780
+ }
2781
+
2782
+ function isCompactionAssistantMessage(message) {
2783
+ if (messageRole(message) !== "assistant") return false
2784
+ const info = isPlainObject(message?.info) ? message.info : message
2785
+ return (
2786
+ info?.summary === true ||
2787
+ info?.agent === "compaction" ||
2788
+ info?.mode === "compaction" ||
2789
+ message?.agent === "compaction" ||
2790
+ message?.mode === "compaction"
2791
+ )
2792
+ }
2793
+
2794
+ function compactionEventIdentity(event) {
2795
+ const candidates = [
2796
+ event?.id,
2797
+ event?.properties?.compactionID,
2798
+ event?.properties?.summaryID,
2799
+ event?.properties?.messageID,
2800
+ event?.properties?.id,
2801
+ event?.data?.compactionID,
2802
+ event?.data?.summaryID,
2803
+ event?.data?.messageID,
2804
+ event?.data?.id,
2805
+ ]
2806
+ const identity = candidates.find(
2807
+ (candidate) => typeof candidate === "string" && candidate.length > 0,
2808
+ )
2809
+ return identity && identity.length <= MAX_GOAL_META_LENGTH ? identity : ""
2639
2810
  }
2640
2811
 
2641
2812
  function messageParentID(message) {
@@ -2771,6 +2942,7 @@ function continuationSnapshot(messages, ownedMessages = currentRuntime().ownedPl
2771
2942
  .reverse()
2772
2943
  .find((message) =>
2773
2944
  (messageRole(message) === "assistant" || messageRole(message) === "user") &&
2945
+ !isCompactionAssistantMessage(message) &&
2774
2946
  !isPluginGeneratedMessage(message, ownedMessages),
2775
2947
  )
2776
2948
  return {
@@ -2939,6 +3111,11 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2939
3111
  stopReason: "",
2940
3112
  promptFailures: 0,
2941
3113
  formatFailures: 0,
3114
+ compactionEpoch: 0,
3115
+ stalledCompactions: 0,
3116
+ lastCompactionEventID: "",
3117
+ messageSeenSinceCompaction: true,
3118
+ compactionSourceAssistantMessageID: "",
2942
3119
  executionContext: normalizeExecutionContext(
2943
3120
  meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID),
2944
3121
  ),
@@ -3893,6 +4070,110 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3893
4070
  cwd: pluginOptions.cwd || directory,
3894
4071
  })
3895
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
+ }
3896
4177
 
3897
4178
  // Each session owns an independent snapshot, ledger, write chain, and
3898
4179
  // lifetime lease. A project can therefore host any number of unrelated goal
@@ -4397,18 +4678,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4397
4678
  sessionID,
4398
4679
  goalID,
4399
4680
  runID,
4681
+ compactionEpoch,
4400
4682
  baselineMessages,
4401
4683
  { refreshMessages = false } = {},
4402
4684
  ) => {
4403
4685
  const goalBeforeRefresh = activeGoal(sessionID, goalID, runID)
4404
- if (!goalBeforeRefresh) return null
4686
+ if (!goalBeforeRefresh || goalBeforeRefresh.compactionEpoch !== compactionEpoch) return null
4405
4687
  const hostMessages = refreshMessages
4406
4688
  ? await sessionApi.messages(sessionID, {
4407
4689
  limit: goalBeforeRefresh.options.maxRecentMessages,
4408
4690
  })
4409
4691
  : baselineMessages
4410
4692
  const goal = activeGoal(sessionID, goalID, runID)
4411
- if (!goal) return null
4693
+ if (!goal || goal.compactionEpoch !== compactionEpoch) return null
4412
4694
  const messages = Array.isArray(hostMessages)
4413
4695
  ? hostMessages.slice(-goal.options.maxRecentMessages)
4414
4696
  : []
@@ -4417,12 +4699,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4417
4699
 
4418
4700
  if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null
4419
4701
 
4420
- const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID)
4421
- if (isPlanAgent(currentContext?.agent)) {
4702
+ const activeRestrictedAgent = await restrictedAgentFor(sessionID)
4703
+ if (activeRestrictedAgent) {
4704
+ const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
4422
4705
  await pauseActiveGoal(sessionID, {
4423
- stopReason: "plan agent active",
4424
- status: "Auto-continue paused because the active agent switched to Plan.",
4425
- 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}.`,
4426
4709
  })
4427
4710
  return null
4428
4711
  }
@@ -4523,12 +4806,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4523
4806
  const sourceAssistantMessageID = refreshed.latestAssistantID || "<no-assistant>"
4524
4807
  if (
4525
4808
  goal.continuationClaim?.runId === runID &&
4809
+ goal.continuationClaim?.compactionEpoch === compactionEpoch &&
4526
4810
  goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID
4527
4811
  ) {
4528
4812
  return null
4529
4813
  }
4530
4814
 
4531
- goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
4815
+ goal.continuationClaim = { runId: runID, compactionEpoch, sourceAssistantMessageID }
4532
4816
  const claimPersisted = await persist(sessionID)
4533
4817
  if (!claimPersisted && persistenceOptions.persistState) {
4534
4818
  goal.continuationClaim = null
@@ -4545,7 +4829,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4545
4829
  })
4546
4830
  return null
4547
4831
  }
4548
- return goal
4832
+ // Let an already-published compaction event invalidate this claim before
4833
+ // the caller enters promptAsync. The final epoch check is the atomic edge:
4834
+ // a claim is valid only while its context epoch is still current.
4835
+ await Promise.resolve()
4836
+ return activeGoal(sessionID, goalID, runID)?.compactionEpoch === compactionEpoch
4837
+ ? goal
4838
+ : null
4549
4839
  }
4550
4840
 
4551
4841
  const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => {
@@ -4802,6 +5092,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
4802
5092
  requireCurrent: false,
4803
5093
  })
4804
5094
  }
5095
+ // Hand the session title back to the user now that no goal owns it.
5096
+ if (clearStillCurrent) await restoreSessionTitle(sessionID)
4805
5097
  replaceCommandOutputText(
4806
5098
  output,
4807
5099
  !clearStillCurrent
@@ -5169,6 +5461,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5169
5461
  `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`,
5170
5462
  )
5171
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
+
5172
5473
  // Replace the focused goal (cleanupGoal discards it); backgrounded goals
5173
5474
  // for this session are preserved. Use `/goal add` to keep the current
5174
5475
  // goal and add another. Clear any ordered-sequence flag so the new
@@ -5180,11 +5481,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5180
5481
  registerSessionGoal(goal)
5181
5482
  focusGoal(sessionID, goal)
5182
5483
  await persist(sessionID)
5183
- announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
5184
- goal,
5185
- transition: replacedGoal ? "replaced-active" : "active",
5186
- expectedState: "active",
5187
- })
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
+ )
5188
5502
  replaceCommandOutputText(
5189
5503
  output,
5190
5504
  [
@@ -5195,14 +5509,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5195
5509
  "",
5196
5510
  ]
5197
5511
  : []),
5198
- `New active goal: ${goal.condition}`,
5512
+ heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`,
5199
5513
  goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
5200
5514
  goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
5201
5515
  goal.mode !== "normal" ? `Mode: ${goal.mode}` : null,
5202
5516
  "",
5203
- "Start working toward this goal now.",
5204
- "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.",
5205
- "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
+ ]),
5206
5531
  `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`,
5207
5532
  "",
5208
5533
  `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
@@ -5211,7 +5536,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5211
5536
  ]
5212
5537
  .filter((line) => line !== null)
5213
5538
  .join("\n"),
5214
- { 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 },
5215
5542
  )
5216
5543
  },
5217
5544
 
@@ -5328,15 +5655,59 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5328
5655
  if (event?.type === "session.compacted") {
5329
5656
  const sessionID = getSessionID(event)
5330
5657
  const goal = goalStates.get(sessionID)
5331
- if (!goal) return
5658
+ if (!goal || goal.stopped) return
5659
+ const identity = compactionEventIdentity(event)
5660
+ if (identity) {
5661
+ if (identity === goal.lastCompactionEventID) return
5662
+ goal.lastCompactionEventID = identity
5663
+ } else if (goal.compactionEpoch > 0 && !goal.messageSeenSinceCompaction) {
5664
+ // A real OpenCode `session.compacted` carries only `sessionID` (SDK:
5665
+ // EventSessionCompacted has no id/compactionID/summaryID/messageID and
5666
+ // no sync variant), so compactionEventIdentity() returns "" for every
5667
+ // host-delivered compaction and the identity dedup above never fires
5668
+ // in production. Recognize a re-delivery by the absence of message
5669
+ // activity instead: a genuine new compaction is always preceded by
5670
+ // messages, because the context has to grow again to trigger one.
5671
+ return
5672
+ }
5673
+ goal.messageSeenSinceCompaction = false
5674
+
5675
+ goal.compactionEpoch += 1
5676
+ goal.stalledCompactions += 1
5677
+ goal.compactionSourceAssistantMessageID =
5678
+ goal.continuationClaim?.runId === goal.runId
5679
+ ? goal.continuationClaim.sourceAssistantMessageID
5680
+ : ""
5332
5681
  goal.messageIDs = new Set()
5333
5682
  goal.totalTokens = 0
5334
- // Compaction rewrites the context: a continuation claim for a
5335
- // pre-compaction source turn must not suppress the post-compaction
5336
- // continuation (the recent tail can still end on the same assistant
5337
- // message, which would otherwise stall the goal loop until the user
5338
- // nudges it).
5683
+ // Compaction rewrites the context. The epoch-scoped claim lets the same
5684
+ // retained assistant source continue once in the new epoch without
5685
+ // allowing duplicate idle delivery to continue it twice.
5339
5686
  goal.continuationClaim = null
5687
+
5688
+ // An idle handler can already have persisted its source claim when the
5689
+ // compaction lands. Abort its cooldown and release the per-session guard;
5690
+ // the epoch checks around promptAsync prevent that stale handler from
5691
+ // sending while allowing the post-compaction idle to start immediately.
5692
+ currentRuntime().continuationControllers.get(sessionID)?.abort()
5693
+ currentRuntime().continuationControllers.delete(sessionID)
5694
+ activeContinues.delete(sessionID)
5695
+
5696
+ if (goal.stalledCompactions >= MAX_STALLED_COMPACTIONS) {
5697
+ await pauseActiveGoal(sessionID, {
5698
+ stopReason: "stalled compaction",
5699
+ status: `Goal paused after ${goal.stalledCompactions} compactions without a productive assistant or tool turn.`,
5700
+ history: `Paused after ${goal.stalledCompactions} compactions without productive non-compaction work.`,
5701
+ })
5702
+ if (typeof client?.session?.abort === "function") {
5703
+ try {
5704
+ await sessionApi.abort(sessionID)
5705
+ } catch (error) {
5706
+ await logPluginError(client, "Failed to abort a stalled compaction loop", error)
5707
+ }
5708
+ }
5709
+ return
5710
+ }
5340
5711
  await persist(sessionID)
5341
5712
  return
5342
5713
  }
@@ -5344,6 +5715,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5344
5715
  if (event?.type === "message.updated") {
5345
5716
  const message = messageInfoFromEvent(event)
5346
5717
  if (!message) return
5718
+ const messageEnvelope =
5719
+ event?.properties?.message || event?.data?.message || message
5347
5720
 
5348
5721
  const currentMessageID = messageID(message)
5349
5722
  if (!currentMessageID) return
@@ -5353,6 +5726,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5353
5726
  const goal = goalStates.get(currentSessionID)
5354
5727
  if (!goal) return
5355
5728
 
5729
+ // Any message traffic for this goal marks the current compaction epoch
5730
+ // as having seen activity, which is what lets an identity-less
5731
+ // `session.compacted` re-delivery be told apart from a real one. Recorded
5732
+ // before the stale-redelivery guard below: a message that is stale for
5733
+ // token accounting still proves the host is delivering message events.
5734
+ goal.messageSeenSinceCompaction = true
5735
+
5356
5736
  // Skip stale re-deliveries from a prior budget window or a replaced goal.
5357
5737
  // resetGoalBudget and cleanupGoal both leave seenTokens entries in place
5358
5738
  // so this guard can fire: if an ID is already recorded in seenTokens but
@@ -5395,6 +5775,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5395
5775
 
5396
5776
  if (
5397
5777
  messageRole(message) === "assistant" &&
5778
+ !isCompactionAssistantMessage(messageEnvelope) &&
5779
+ currentMessageID !== goal.compactionSourceAssistantMessageID &&
5398
5780
  currentOutputTokens > previousOutputTokens &&
5399
5781
  runtime.suppressedCommandAssistants.get(currentMessageID) !== currentSessionID
5400
5782
  ) {
@@ -5402,6 +5784,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5402
5784
  changed = true
5403
5785
  }
5404
5786
 
5787
+ // Productive-turn reset. `message.updated` carries only
5788
+ // `properties.info` (SDK: EventMessageUpdated) and never any parts —
5789
+ // tool parts arrive on the separate `message.part.updated` event, which
5790
+ // this plugin does not observe. A messageHasToolCall() check against the
5791
+ // event envelope is therefore always false and cannot serve as the reset
5792
+ // signal. Growing output tokens is the signal that does work: an
5793
+ // assistant turn that calls a tool still emits output tokens for it.
5794
+ if (
5795
+ messageRole(message) === "assistant" &&
5796
+ !isCompactionAssistantMessage(messageEnvelope) &&
5797
+ currentMessageID !== goal.compactionSourceAssistantMessageID &&
5798
+ currentOutputTokens > previousOutputTokens &&
5799
+ goal.stalledCompactions > 0
5800
+ ) {
5801
+ goal.stalledCompactions = 0
5802
+ goal.compactionSourceAssistantMessageID = ""
5803
+ changed = true
5804
+ }
5805
+
5405
5806
  if (changed) await persist(messageSessionID(message))
5406
5807
  return
5407
5808
  }
@@ -5497,10 +5898,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5497
5898
  if (!goal || goal.stopped || activeContinues.has(sessionID)) return
5498
5899
  const goalID = goal.goalId
5499
5900
  const runID = goal.runId
5901
+ const compactionEpoch = goal.compactionEpoch
5500
5902
 
5501
5903
  const continueToken = randomUUID()
5502
5904
  const continueController = new AbortController()
5503
5905
  let claimedSourceAssistantMessageID = ""
5906
+ let claimedCompactionEpoch = -1
5504
5907
  activeContinues.set(sessionID, continueToken)
5505
5908
  currentRuntime().continuationControllers.set(sessionID, continueController)
5506
5909
  try {
@@ -5513,7 +5916,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5513
5916
  ? hostMessages.slice(-goal.options.maxRecentMessages)
5514
5917
  : []
5515
5918
  const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID)
5516
- if (!activeGoalAfterMessages) return
5919
+ if (
5920
+ !activeGoalAfterMessages ||
5921
+ activeGoalAfterMessages.compactionEpoch !== compactionEpoch
5922
+ ) return
5517
5923
  if (!activeGoalAfterMessages.executionContext) {
5518
5924
  activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages)
5519
5925
  }
@@ -5526,9 +5932,21 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5526
5932
  const assistantChanged = summarizeText(latestText) !== summarizeText(previousAssistantText)
5527
5933
  const assistantRepeated =
5528
5934
  latestAssistantID && latestAssistantID === activeGoalAfterMessages.lastAssistantMessageID
5529
- const activationBoundary =
5935
+ // A retained pre-compaction assistant must not be scored as fresh
5936
+ // progress, so the compaction source gates checkpointing and the stall
5937
+ // heuristics. It must NOT gate the terminal checks: a [goal:complete] or
5938
+ // [goal:blocked] on that retained turn has not been acted on yet — it
5939
+ // survived the compaction unprocessed — and swallowing it discards a
5940
+ // real result and spends another continuation to re-derive it.
5941
+ const terminalBoundary =
5530
5942
  currentRuntime().suppressedCommandAssistants.get(latestAssistantID) === sessionID ||
5531
5943
  activeGoalAfterMessages.skipNextTerminalCheck === true
5944
+ const activationBoundary =
5945
+ terminalBoundary ||
5946
+ Boolean(
5947
+ activeGoalAfterMessages.compactionSourceAssistantMessageID &&
5948
+ activeGoalAfterMessages.compactionSourceAssistantMessageID === latestAssistantID,
5949
+ )
5532
5950
  activeGoalAfterMessages.skipNextTerminalCheck = false
5533
5951
 
5534
5952
  if (!activationBoundary && latestText && (!assistantRepeated || assistantChanged)) {
@@ -5555,6 +5973,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5555
5973
  const sourceAssistantMessageID = latestAssistantID || "<no-assistant>"
5556
5974
  if (
5557
5975
  activeGoalAfterMessages.continuationClaim?.runId === runID &&
5976
+ activeGoalAfterMessages.continuationClaim?.compactionEpoch === compactionEpoch &&
5558
5977
  activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID ===
5559
5978
  sourceAssistantMessageID
5560
5979
  ) {
@@ -5569,7 +5988,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5569
5988
  let completionUnverified = false
5570
5989
  let blockerUnstated = false
5571
5990
 
5572
- if (!activationBoundary && goalIsComplete(latestText)) {
5991
+ if (!terminalBoundary && goalIsComplete(latestText)) {
5573
5992
  const evidence = extractCompletionEvidence(latestText)
5574
5993
  if (evidence) {
5575
5994
  await announceAudit(
@@ -5731,7 +6150,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5731
6150
  "completion-unverified",
5732
6151
  "Assistant output [goal:complete] without a [goal:evidence] line; completion rejected, continuing.",
5733
6152
  )
5734
- } else if (!activationBoundary && goalIsBlocked(latestText)) {
6153
+ } else if (!terminalBoundary && goalIsBlocked(latestText)) {
5735
6154
  const reason = extractBlockedReason(latestText)
5736
6155
  if (reason) {
5737
6156
  await announceAudit(
@@ -5801,6 +6220,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
5801
6220
  sessionID,
5802
6221
  goalID,
5803
6222
  runID,
6223
+ compactionEpoch,
5804
6224
  messages,
5805
6225
  )
5806
6226
  if (!claimedGoal) return
@@ -6014,12 +6434,16 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6014
6434
  sessionID,
6015
6435
  goalID,
6016
6436
  runID,
6437
+ compactionEpoch,
6017
6438
  messages,
6018
6439
  { refreshMessages: cooldownWaited },
6019
6440
  )
6020
6441
  if (!activeGoalBeforePrompt) return
6021
6442
  claimedSourceAssistantMessageID =
6022
6443
  activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || ""
6444
+ claimedCompactionEpoch =
6445
+ activeGoalBeforePrompt.continuationClaim?.compactionEpoch ?? -1
6446
+ if (claimedCompactionEpoch !== activeGoalBeforePrompt.compactionEpoch) return
6023
6447
 
6024
6448
  const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt)
6025
6449
  if (budgetWrapup) {
@@ -6115,6 +6539,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6115
6539
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
6116
6540
  const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
6117
6541
  if (
6542
+ activeGoalAfterPrompt?.continuationClaim?.compactionEpoch ===
6543
+ claimedCompactionEpoch &&
6118
6544
  activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
6119
6545
  claimedSourceAssistantMessageID
6120
6546
  ) {
@@ -6133,6 +6559,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6133
6559
  } else {
6134
6560
  const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
6135
6561
  if (
6562
+ activeGoalAfterPrompt?.continuationClaim?.compactionEpoch ===
6563
+ claimedCompactionEpoch &&
6136
6564
  activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID ===
6137
6565
  claimedSourceAssistantMessageID
6138
6566
  ) {
@@ -6164,6 +6592,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6164
6592
  if (activeGoalAfterError) {
6165
6593
  if (
6166
6594
  claimedSourceAssistantMessageID &&
6595
+ activeGoalAfterError.continuationClaim?.compactionEpoch ===
6596
+ claimedCompactionEpoch &&
6167
6597
  activeGoalAfterError.continuationClaim?.sourceAssistantMessageID ===
6168
6598
  claimedSourceAssistantMessageID
6169
6599
  ) {
@@ -6297,6 +6727,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
6297
6727
 
6298
6728
  // register_command toggle: when disabled, the plugin does not own
6299
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
+
6300
6761
  if (!registerCommand) {
6301
6762
  delete hooks["command.execute.before"]
6302
6763
  }
@@ -6433,6 +6894,14 @@ export const testInternals = {
6433
6894
  isIdleEvent,
6434
6895
  isPluginCommandMessage,
6435
6896
  isPluginContinuationMessage,
6897
+ isPlanAgent,
6898
+ buildSessionTitle,
6899
+ formatCompactDuration,
6900
+ formatCompactTokens,
6901
+ goalStatusIcon,
6902
+ looksLikePluginSessionTitle,
6903
+ isRestrictedAgent,
6904
+ normalizeRestrictedAgents,
6436
6905
  isPluginGeneratedMessage,
6437
6906
  legacyStateFilePaths,
6438
6907
  messageHasToolCall,