Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- 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.
- 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.

- Hold new goals created while a planning-only agent is active instead of starting them. Previously the idle guard caught a Plan-agent session only on the *next* idle, so `/goal <objective>` in Plan mode created a live goal and the routed command text still told the model to start working. Such a goal is now recorded with stop reason `plan agent active`, keeps its budget, and is announced through a read-only control turn that explicitly tells the model not to begin work.
- Add `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`) so the planning-only restriction can name other agents or be released entirely. The default-on behavior is pinned by the mutation contract.

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ This project is independently implemented for OpenCode. Product names used elsew
| Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
| Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
| 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) |

See the [compatibility policy](docs/compatibility.md) for the supported public
surface and versioning expectations.
Expand Down Expand Up @@ -401,6 +402,32 @@ await GoalPlugin(

`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.

## Status indicator

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:

```
▶ ship the release · 3/10 · 2m · 45k/200k
```

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.

```json
{
"plugin": [
["opencode-goal-plugin", { "sessionTitleStatus": true }]
]
}
```

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.

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.

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.

This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step.

## Plan-mode safety

A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default:
Expand Down
50 changes: 50 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool
blocking, and parent-correlated lifecycle suppression. The system transform
remains registered as additional protection for hosts that support it.

## OpenCode 2

**Status: not supported, and not yet tested.**

The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as
`>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made
without a verified run behind it, and the project has not yet exercised the
plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than
as known-broken.

### What already exists in this direction

- `createOpenCodeSessionApi` speaks both the legacy generated-client shape
(`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`),
selected per operation and remembered after the first success. The
`sdkShape: "flat"` option pins the flattened shape for embedded clients.
- Only read-only operations are ever replayed against the alternate shape, so a
shape probe can never duplicate a mutating call. This invariant is pinned by
the mutation contract.

### What a supported v2 claim would require

Before the pin is widened, all of the following need to pass against a real
OpenCode 2 build, not a mock:

1. Plugin load and hook registration through the v2 plugin entrypoint.
2. `command.execute.before`, `event`, `experimental.chat.system.transform`,
`experimental.session.compacting`, and `experimental.compaction.autocontinue`
firing with the shapes the plugin expects.
3. The execution-context signals (`chat.message`, `chat.params`,
`session.updated`) still reporting the active agent, which the planning-only
restriction depends on.
4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`,
`abort`) under whichever argument shape v2 ships.
5. Goal-specific compaction context and recovery of running child sessions after
a plugin restart, which are the areas most likely to differ.

### Configuration

This plugin is **server-only**: `package.json` exports the root and
`opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its
configuration therefore lives entirely in `opencode.json` (the `plugin` and
`command` keys) on any OpenCode line.

Plugins that *do* ship a TUI component are registered in a second file whose
location differs between OpenCode lines, and those formats must not be mixed.
That distinction does not apply here — including for the
[status indicator](../README.md#status-indicator), which reaches the TUI through
the session title rather than through a TUI plugin.

## Versioning

Semantic-versioning intent is:
Expand Down
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,18 @@ export interface GoalPluginOptions {
/** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */
registerAgents?: boolean

/**
* Mirror live goal status into the OpenCode session title, which the TUI
* renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`),
* giving unattended runs a continuous heartbeat without a TUI plugin.
*
* The session's original title is captured before the first overwrite and
* restored by `/goal clear`. Title updates are cosmetic: a failure is logged
* at debug level and never interrupts the goal loop.
* @default false
*/
sessionTitleStatus?: boolean

/**
* Agent names treated as planning-only. A goal created while one of these
* agents is active is recorded but held paused instead of starting, and
Expand Down
172 changes: 172 additions & 0 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ function createRuntimeState() {
seenIdleEventIDs: new Set(),
sessionStatuses: new Map(),
sessionExecutionContexts: new Map(),
// Session-title indicator: the user's own title, captured before the plugin
// first overwrites it, and the last title the plugin wrote (so an unchanged
// render skips the API call).
sessionTitles: new Map(),
appliedTitles: new Map(),
pendingCommandTurns: new Map(),
activeCommandTurns: new Map(),
commandOutputs: new WeakMap(),
Expand Down Expand Up @@ -422,6 +427,64 @@ function isPlanAgent(agent) {
return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS)
}

// Session-title status indicator. OpenCode renders the session title
// persistently, so mirroring goal progress into it gives unattended runs a
// continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it
// overwrites a user-visible field.
const SESSION_TITLE_OBJECTIVE_LIMIT = 48
const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"]

// The title sits in a narrow column, so every field is abbreviated hard.
function formatCompactDuration(ms) {
const totalSeconds = Math.max(0, Math.round(ms / 1000))
if (totalSeconds < 60) return `${totalSeconds}s`
const totalMinutes = Math.floor(totalSeconds / 60)
if (totalMinutes < 60) return `${totalMinutes}m`
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return minutes ? `${hours}h${minutes}m` : `${hours}h`
}

function formatCompactTokens(tokens) {
const value = toNonNegativeInteger(tokens)
if (value < 1000) return String(value)
if (value < 1_000_000) {
const thousands = value / 1000
return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`
}
const millions = value / 1_000_000
return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m`
}

// Blocked and paused are distinct to a watching human: one needs input, the
// other just needs a resume.
function goalStatusIcon(goal) {
if (goal.blockedReason) return "⛔"
if (goal.stopped) return "⏸"
return "▶"
}

// One-line goal status for the session title, e.g.
// "▶ ship the release · 3/10 · 2m · 45k/200k".
function buildSessionTitle(goal, now = Date.now()) {
const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt)
return [
`${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`,
`${goal.turnCount}/${goal.options.maxTurns}`,
formatCompactDuration(elapsedMs),
`${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`,
].join(" · ")
}

// Recognize a title this plugin wrote. The captured "original" is what
// `/goal clear` restores, so capturing one of our own status lines would make
// clear promote a stale status string to the permanent session title. That is
// exactly the state a hard process kill leaves behind.
function looksLikePluginSessionTitle(title) {
const text = typeof title === "string" ? title.trimStart() : ""
return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `))
}

// Stop reason for a goal held because a planning-only agent is active. The
// built-in `plan` case keeps its established wording so persisted state and
// existing consumers stay stable.
Expand Down Expand Up @@ -949,6 +1012,8 @@ function clearRuntimeState() {
runtime.seenIdleEventIDs.clear()
runtime.sessionStatuses.clear()
runtime.sessionExecutionContexts.clear()
runtime.sessionTitles.clear()
runtime.appliedTitles.clear()
runtime.pendingCommandTurns.clear()
runtime.activeCommandTurns.clear()
runtime.ownedPluginMessages.clear()
Expand Down Expand Up @@ -2162,6 +2227,26 @@ async function logPluginWarning(client, message) {
return logPluginMessage(client, "warn", message)
}

// Cosmetic failures (session-title updates) log at debug and never fall back to
// the console: a title that failed to render must not look like a goal fault.
async function logPluginDebug(client, message, error) {
if (!client?.app?.log) return
try {
await client.app.log({
body: {
service: "opencode-goal-plugin",
level: "debug",
message,
...(error === undefined
? {}
: { extra: { error: error?.message || error?.name || String(error) } }),
},
})
} catch {
// Diagnostics must never affect the goal loop.
}
}

function parseGoalArguments(args, defaults) {
const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
const condition = []
Expand Down Expand Up @@ -3990,6 +4075,55 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
// agent. Defaults to false: unattended work must not escape Plan mode.
const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true

// Opt-in: mirrors live goal status into the OpenCode session title, which the
// TUI renders persistently. Off by default because it overwrites a
// user-visible field.
const sessionTitleStatus = pluginOptions.sessionTitleStatus === true

// Title updates are cosmetic: every path swallows errors after logging at
// debug level so a failure can never interrupt the goal loop.
const syncSessionTitle = async (sessionID) => {
if (!sessionTitleStatus || !sessionID) return
const goal = goalStates.get(sessionID)
if (!goal) return
const title = buildSessionTitle(goal)
if (currentRuntime().appliedTitles.get(sessionID) === title) return
try {
if (!currentRuntime().sessionTitles.has(sessionID)) {
const session = await sessionApi.get(sessionID)
const existing = typeof session?.title === "string" ? session.title : ""
// A status line left behind by a previous process is not the user's
// title; capture empty so clear leaves the host's title alone rather
// than restoring stale goal status.
currentRuntime().sessionTitles.set(
sessionID,
looksLikePluginSessionTitle(existing) ? "" : existing,
)
}
await sessionApi.update(sessionID, { title })
currentRuntime().appliedTitles.set(sessionID, title)
} catch (error) {
await logPluginDebug(client, "Failed to update session title", error)
}
}

const restoreSessionTitle = async (sessionID) => {
if (!sessionTitleStatus || !sessionID) return
const runtime = currentRuntime()
if (!runtime.sessionTitles.has(sessionID)) return
const original = runtime.sessionTitles.get(sessionID)
runtime.sessionTitles.delete(sessionID)
runtime.appliedTitles.delete(sessionID)
// Empty means there was nothing genuine to restore (no title, or the
// session only carried a status line from a previous process).
if (!original) return
try {
await sessionApi.update(sessionID, { title: original })
} catch (error) {
await logPluginDebug(client, "Failed to restore session title", error)
}
}

// The restricted agent currently driving this session, or "" when execution
// is permitted. Reads the execution context the host reports through
// `chat.message`, `chat.params`, and `session.updated`.
Expand Down Expand Up @@ -4930,6 +5064,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
requireCurrent: false,
})
}
// Hand the session title back to the user now that no goal owns it.
if (clearStillCurrent) await restoreSessionTitle(sessionID)
replaceCommandOutputText(
output,
!clearStillCurrent
Expand Down Expand Up @@ -6563,6 +6699,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})

// register_command toggle: when disabled, the plugin does not own
// a slash command and only the event/transform/compaction hooks remain.
// Session-title indicator: rather than threading a sync call through every
// state-mutating site (a missed one shows the user a stale status), wrap the
// two hooks that gate all state change. The sync no-ops when the rendered
// title is unchanged, and runs in `finally` so the displayed status matches
// the state actually reached even if a hook throws.
if (sessionTitleStatus) {
for (const hookName of ["command.execute.before", "event"]) {
const original = hooks[hookName]
if (typeof original !== "function") continue
hooks[hookName] = async (...args) => {
try {
return await original(...args)
} finally {
let titleSessionID = ""
if (hookName === "event") {
// `message.updated` streams many times per assistant turn. Awaiting
// a title sync on each would put an API round-trip in the streaming
// path for a cosmetic update; idle, compaction, and interruption
// events already cover every state the indicator renders.
if (args[0]?.event?.type !== "message.updated") {
titleSessionID = getSessionID(args[0]?.event)
}
} else {
titleSessionID = args[0]?.sessionID
}
await syncSessionTitle(titleSessionID)
}
}
}
}

if (!registerCommand) {
delete hooks["command.execute.before"]
}
Expand Down Expand Up @@ -6700,6 +6867,11 @@ export const testInternals = {
isPluginCommandMessage,
isPluginContinuationMessage,
isPlanAgent,
buildSessionTitle,
formatCompactDuration,
formatCompactTokens,
goalStatusIcon,
looksLikePluginSessionTitle,
isRestrictedAgent,
normalizeRestrictedAgents,
isPluginGeneratedMessage,
Expand Down
Loading