Skip to content

feat(core): add /goal to keep a session working until an objective is met - #59262

Closed
neriousy wants to merge 7 commits into
session-settledfrom
session-goal
Closed

neriousy wants to merge 7 commits into
session-settledfrom
session-goal

Conversation

@neriousy

@neriousy neriousy commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Issue for this PR

Refs #27167. Stacked on #59258, which is stacked on #58508 (the session idle hook). Merge those first; this PR then retargets to dev.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds /goal <objective>. The Session keeps working, turn after turn, until a reviewer decides the objective is met. It's a built-in plugin (packages/core/src/plugin/goal.ts) that uses only public plugin APIs. The design draws on the harness notes in specs/goal/.

The loop

The whole goal runs as one execution. Each continuation is admitted from the idle hook, so the Session never goes idle in between, and the user gets one notification at the end. (opencode run sends its message as a plain prompt and has no slash commands, so /goal isn't available there yet.)

sequenceDiagram
    actor User
    participant Goal as Goal plugin
    participant Runner as Session runner
    participant Model
    participant Reviewer as Reviewer (session.generate)

    User->>Goal: /goal <objective>
    Goal->>Goal: save goal in Session metadata
    Goal->>Runner: kickoff (notice "Goal: <objective>")
    loop until done, paused or stalled
        Runner->>Model: steps (tools, edits, …)
        Runner->>Goal: idle hook { sessionID, background }
        alt background subagent still running
            Goal-->>Runner: return (its result wakes the Session later)
        else
            Goal->>Reviewer: has the objective been met?
            Reviewer-->>Goal: {"verdict": …, "reason": …}
            Goal->>Runner: hidden continuation (on "continue")
        end
    end
    Runner-->>User: one execution, one notification
Loading

Goal states

stateDiagram-v2
    [*] --> Active: /goal <objective>
    Active --> Active: reviewer "continue" → hidden continuation
    Active --> Active: subagent running / reviewer "wait" → no-op
    Active --> Complete: reviewer "done"
    Active --> Paused: reviewer "blocked" or "paused"
    Active --> Paused: user stops · execution fails · 3 stalled rounds · 50 rounds
    Active --> Paused: no usable verdict twice · hook error · server restarted
    Active --> Paused: idle 30 min waiting on background work · reviewer request too large after compacting
    Paused --> Active: /goal resume (fresh round budget)
    Active --> [*]: /goal clear
    Paused --> [*]: /goal clear
    Complete --> Active: /goal <new objective>
Loading

Details

Reviewer ctx.session.generate runs the Session's own model on the full history, tool results included, and shares the cached prefix. It answers with one JSON line: done, continue, blocked, paused (the user asked to stop) or wait (offered only while background shells run, and never for long-running servers). An unusable answer is retried once. The reviewer runs after the last step, before the next step's automatic compaction, so its request can overflow. On a context overflow the plugin admits a compaction; the runner compacts and runs the hook again. A second overflow in the same round pauses.
Continuation A steered synthetic message that restates the objective, wrapped in <untrusted_objective>, plus the reviewer's reason and next step. It has no description, so timelines don't show it; clients show the running goal from its metadata instead. User input admitted at the same boundary still goes first.
Progress A round stalls when the model made no tool calls since the last kickoff or continuation, or when the reviewer gives the same reason as the round before. Three stalled rounds in a row pause the goal with "Not making progress: ". A hidden backstop of 50 rounds catches anything stall detection misses. Neither number is shown in the UI.
Kickoff A synthetic message described as "Goal: ", so timelines show one notice row instead of the whole template. A /goal with attachments (files, agents, skills) sends the kickoff as the user's message instead, because only a prompt carries attachments. Titles come from the first user message, so an untitled Session started by /goal is named after the objective.
State Session metadata, key opencode.goal, shaped by SessionGoal.State in @opencode/schema/session-goal: { id, sessionID, objective, status, reason, rounds, maxRounds, boot, updatedAt }. It's durable, and clients decode it with SessionGoal.fromMetadata. Children and forks copy metadata, so a goal only acts for the sessionID that set it.
Background work While a background subagent of the Session or its descendants runs, the reviewer isn't asked. Background shells are listed for the reviewer instead of blocking, so a dev server can't stall the goal.
Compaction The goal lives in metadata, so compaction can't lose it, and every continuation restates the objective. Two hooks cover the gap in between. The compaction hook asks the summary to keep the goal and its progress, as a note placed right before the summary prompt. It skips native compactions, where the provider may keep that message in the replay window after the goal is gone; the hook event now carries native for this. The context hook adds a one-line reminder of the objective when no kickoff or continuation is in view, for example in the middle of a round right after a compaction. The reminder isn't saved and goes near the tail, so the cached prefix stays warm, and it stops once the next continuation lands.
Restart Each process has a boot ID. At startup the plugin pauses active goals from an earlier process among the 100 most recent Sessions. Any other is paused at its next idle point instead of being resumed by restart recovery.
Waiting An active goal sits idle only while it waits on background work, whose notice wakes the Session. If nothing has run for 30 minutes after an execution ended (a hung subagent, a shell that never exits, a wake that never comes), the goal pauses instead of looking active forever. A new execution cancels the wait.
Races The goal is re-read after the reviewer returns, so a /goal pause or clear sent mid-review wins. Every write the loop makes is guarded: it re-reads and applies only if the goal is still the one it decided on, so a user's command since then is never overwritten. Metadata has no compare-and-set, so a tiny window remains. User commands write unguarded, because they win. A failing hook pauses the goal instead of failing the execution.
Commands /goal <objective> or /goal "<objective>" (replaces any earlier goal, max 4,000 characters), pause, resume and clear. They run immediately, even mid-turn. A bare /goal returns the status and usage as the command error until clients show it.

Not in this PR

  • TUI and app indicators, and the /goal status display.
  • A token budget.
  • Check-ins during long waits.
  • toolChoice: "none" for session.generate. The reviewer request still carries the Session's tools, and the prompt says not to call them. A tool-call-only reply counts as no verdict.

How did you verify your code works?

packages/core/test/plugin/goal.test.ts runs the real internal plugins, commands and runner against TestLLM:

Scenario Expectation
continue, then done One execution (a single idle marker). The kickoff is a "Goal: …" notice, not a user message. The continuation has no description and carries the reason and next step. Complete after 1 round. Fenced JSON is accepted.
reviewer reports a blocker Paused with its reason
same reason four times, with tool calls each round Paused after the third repeat: "Not making progress: …"
three rounds without tool calls Paused: "Not making progress: …"
tool calls and a new reason each round Keeps going; stalls stays 0
two unusable verdicts Paused
background subagent running Reviewer not asked. After the subagent's notice wakes the Session, it is.
user stops the Session Paused, "Stopped by the user"
goal from an earlier process Paused, not continued
child created while the goal is active Copied goal ignored
/goal pause during review Verdict dropped, no continuation
compaction while a goal is active The summary request carries the goal note; the next request carries the reminder, before the user's prompt and not saved
objective in view No reminder on any request
untitled Session Named after the objective
reviewer overflows once Compacts, asks again, completes
reviewer overflows again Paused: request too large
native compaction (unit) No goal note; a regular compaction gets it
goal from an earlier process at startup (unit) Paused by the sweep
idle past 30 minutes (unit, TestClock) Paused; a new execution before the limit cancels it
user pauses after the loop decided (unit) The user's pause and reason stay
quoted objective /goal "Ship the release" sets Ship the release
goal with an attached file Kickoff sent as the user's message with the file
pause / resume / clear Fresh budget on resume. clear keeps other metadata keys.
  • Each guard (ownership, boot, subagent wait, re-read after review, interrupt watcher, retry, same-reason and no-tool stall checks, compaction note, reminder and its visibility check, overflow compaction, title, native skip, startup sweep, wait cancellation, guarded writes) fails its test when removed.
  • session-native-compaction.test.ts checks that compaction hooks see native: true.
  • bun run check passes, and bun run typecheck:tsc passes in packages/core.
  • The full packages/core suite passes except 7 shell-syntax tests in tool-shell and shell-scan, which fail the same way on this machine without this change.

Visual

A looping animation for the desktop app's "What's new" dialog. GitHub only shows its source.

SVG source
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="36" cy="36" r="14" opacity="0.5"/>
  <circle cx="36" cy="36" r="7" opacity="0.5"/>
  <circle cx="36" cy="36" r="2" fill="currentColor"/>
  <path d="M60 36h112" opacity="0.25"/>
  <g>
    <circle cx="80" cy="36" r="4" fill="currentColor" fill-opacity="0.6"/>
    <circle cx="110" cy="36" r="4" fill="currentColor" fill-opacity="0.6"/>
    <circle cx="140" cy="36" r="4" fill="currentColor" fill-opacity="0.6"/>
  </g>
  <circle cx="80" cy="36" r="4" fill="currentColor"><animate attributeName="cx" values="80;80;110;110;140;140;172;172" keyTimes="0;0.15;0.25;0.4;0.5;0.65;0.75;1" dur="4s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;1;1;1;1;1;0;0" keyTimes="0;0.15;0.25;0.4;0.5;0.65;0.75;1" dur="4s" repeatCount="indefinite"/></circle>
  <path d="M190 36l8 8 18-18" stroke-width="3"><animate attributeName="opacity" values="0.2;0.2;1;1" keyTimes="0;0.72;0.78;1" dur="4s" repeatCount="indefinite"/></path>
</svg>

Screenshots / recordings

How the loop looks with the clients in this stack (#59275 TUI, #59276 desktop). Real TUI and server, driven with opencode-drive; only the model and reviewer replies are scripted.

Recording of the goal loop in the TUI

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@neriousy
neriousy added this pull request to stack #59279 October 26, 2026 13:04
@neriousy

Copy link
Copy Markdown
Member Author

Folded into #58508, which now holds the whole /goal stack (idle hook, settled notifications, /goal, TUI and desktop) in one PR.

@neriousy neriousy closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant