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 +7 -0
- package/README.md +59 -1
- package/docs/compatibility.md +50 -0
- package/index.d.ts +28 -0
- package/package.json +1 -1
- package/src/goal-plugin.js +311 -16
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.
|
package/docs/compatibility.md
CHANGED
|
@@ -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
package/src/goal-plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
4485
|
-
if (
|
|
4702
|
+
const activeRestrictedAgent = await restrictedAgentFor(sessionID)
|
|
4703
|
+
if (activeRestrictedAgent) {
|
|
4704
|
+
const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent
|
|
4486
4705
|
await pauseActiveGoal(sessionID, {
|
|
4487
|
-
stopReason:
|
|
4488
|
-
status:
|
|
4489
|
-
history:
|
|
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
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
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
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
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
|
-
|
|
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,
|