Repository navigation
Conversation
# Conflicts: # packages/app/src/session/composer/region.tsx
neriousy
marked this pull request as draft
October 27, 2026 12:33
neriousy
marked this pull request as ready for review
October 27, 2026 13:11
# Conflicts: # packages/gui-plugins/README.md
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue for this PR
Refs #27167
Alternative to #58508. It has the same idle hook,
/goaland TUI, with these differences:session.settled. Here clients work out "done" from data they already sync (part 2).nativeflag on thecompactionhook. The goal doesn't add a note to compaction summaries. It relies on the unsaved reminder and on continuations restating the objective, so the plugin API change is just theidlehook.packages/gui-plugins/src/goal), using the SDK'sComposerPillregistry, instead of a custom capsule in the app.Only one of the two should be merged.
Type of change
What does this PR do?
Adds
/goal <objective>: the Session keeps working, turn after turn, until a reviewer decides the objective is met. Building it needed a reliable notion of "the Session is really idle", so the PR has five parts:idleplugin hook. Lets a plugin continue a Session at the point it would go idle, inside the same execution./goalplugin. Built-in, and it uses only public plugin APIs.1. Session
idlehookThe runner fires
idlewith{ sessionID, background }where a Session would otherwise go idle: work is finished and no input is pending. To keep going, a plugin admits input the normal way, for examplectx.session.synthetic(...). The runner then re-checks the inbox and runs another step in the same execution. If nothing was admitted, the Session goes idle as before.Today a plugin can only react to
session.execution.succeededby queueing a new prompt. That has three problems:Deciding inside the drain avoids all three.
backgroundlists the background shells and subagents still running for the Session or any descendant. It comes fromSessionBackground.outstanding, which walks the in-memory job registry up the parent chain and makes no queries when nothing runs. When the Session's own shell or subagent finishes, its notice wakes the Session and the hook runs again, so a plugin that waits for it can simply return. Work a descendant owns, such as a shell a subagent left running, wakes that descendant instead, so it is listed for information only.restore(...), so a slow hook stays interruptible.steppedis carried throughMoved/Reloaded.SessionHooks.2. "Done" notifications
Desktop and the TUI play the "done" sound on every
session.execution.succeeded. A Session waiting on background subagents ends an execution when its turn ends, and again each time a subagent's result wakes it. So it beeps while the subagents are still working, and again for each one.The clients now work it out from data they already sync. On
session.execution.succeededfor X, they play "done" only if none of X's direct children isrunning:statuscomes fromstore.session.active, which is set fromexecution.startedandsucceeded/failed/interrupted.succeededhandler only, andsubagent_donesounds are unchanged.shell/notifications/notification.tsx. TUI:feature-plugins/system/notifications.ts.Known gap: if a child Session hasn't reached the client's store yet, the check misses it and "done" plays early. That's no worse than today. A server-side event like #58508's
session.settledis the fallback if this turns out to be too fragile.3.
/goalpackages/core/src/plugin/goal.ts. The design draws on the harness notes inspecs/goal/. The whole goal runs as one execution: each continuation is admitted from theidlehook, so the user gets one notification at the end.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 "done" notificationstateDiagram-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>ctx.session.generateruns 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) orwait(offered only while the Session's own background shells run, and never for long-running servers). An unusable answer, includingwaitwhen it wasn't offered, is retried once. On a context overflow the plugin compacts and asks again. A second overflow in the same round pauses.<untrusted_objective>, plus the reviewer's reason and next step. It has no description, so timelines don't show it. User input admitted at the same boundary still goes first./goalwith attachments sends the kickoff as the user's message instead, because only a prompt carries attachments. An untitled Session is named after the objective.opencode.goal, shaped bySessionGoal.Statein@opencode/schema/session-goal:{ id, sessionID, objective, status, reason, rounds, maxRounds, boot, updatedAt }. Clients decode it withSessionGoal.fromMetadata. Children and forks copy metadata, so a goal only acts for thesessionIDthat set it.contexthook adds a short reminder of the objective, which isn't saved, until a kickoff or continuation is in view again. Native compactions included./goal pauseorclearsent mid-review wins. Every write the loop makes re-reads first and applies only if the goal is unchanged, so a user's command is never overwritten. User commands write unguarded. A failing hook pauses the goal instead of failing the execution./goal <objective>or/goal "<objective>"(replaces any earlier goal, max 4,000 characters),pause,resumeandclear. They run immediately, even mid-turn. A bare/goalreturns the status and usage as the command error.opencode runhas no slash commands, so/goalisn't available there yet.4. TUI
flowchart LR Plugin["goal plugin<br/>writes metadata"] -->|session.metadata.updated| Store["client store<br/>(now handled)"] Store --> Status["prompt footer<br/>◎ goal · ◎ goal paused · ◎ goal met"] Status -->|click| Dialog["goal dialog<br/>objective · pause · resume · clear"] Dialog -->|/goal pause| Pluginpackages/client/src/solid/data.tsignoredsession.metadata.updated, so metadata changed only on a full re-read. It now replaces the Session's metadata from the event. It usesreconcile, so dropped keys go away.GoalStatusinfeature-plugins/prompt/goal.tsx, rendered byprompt/footer.tsx). A short item under the prompt beside the subagent/shell counts:◎ goal,◎ goal pausedor◎ goal met.ppause,rresume andxclear depending on the status.◎.5. Desktop
A built-in GUI plugin,
packages/gui-plugins/src/goal, listed insrc/renderer.tsandsrc/main.tslike the other built-ins. It contributes oneComposerPill, so the host renders it in the composer lane next to the activity and todo pills. The app itself has no goal code.Goal,Goal paused(warning tone) orGoal met(success tone).session.commandwith/goal …, and a failure shows an error toast. Like Codex's footer and Claude Code's◎ /goal active, the reviewer's reason isn't shown; a bare/goalreturns it.ComposerPillViewgainsactions(ComposerPillAction): buttons the host draws in one row at the bottom of the pill's menu, as menu items so the keyboard still reaches them. A longtitlemade the menu heading run past the window, so the host now wraps it to three lines and truncates. Both are documented in the SDK, andplugin-host.spec.tscovers actions.SessionGoal.fromMetadata, so a goal copied from a parent isn't shown. The host shows no pills in a subagent's session.i18n/en.ts.Not in this PR
toolChoice: "none"forsession.generate. The reviewer request still carries the Session's tools; the prompt says not to call them.How did you verify your code works?
Idle hook (
packages/core/test/session-runner.test.ts):resume: falseandresume: true.backgroundholds the Session's own and its descendants' background work, and not another Session's.Done notifications (the TUI's
notifications.test.ts):/goal(packages/core/test/plugin/goal.test.ts, 14 full-stack scenarios against TestLLM, plusgoal-watchdog.test.ts):waitisn't offered and counts as unusable/goal pauseduring review / after the loop decidedpause/resume/clearclearkeeps other metadata keysEach guard fails its test when removed.
Clients:
solid-data.test.ts: a metadata update that drops a key removes it.prompt-footer.test.tsx:◎ goalmuted, with no objective or round counter;opencode-drive:/goal, a hidden continuation, met, the dialog, then a second goal stopped with Esc and paused.Checks after rebasing onto
dev:bun run checkpasses.lint:changedis clean.Visual
A looping animation for the desktop app's "What's new" dialog. GitHub only shows its source.
SVG source
Screenshots / recordings
TUI. Real TUI and server from this branch, driven with
opencode-drive; only the model and reviewer replies are scripted. The recording shows/goal, a continuation, met, the dialog, then a second goal stopped with Esc.◎ goalunder the prompt, at the right.Desktop. The built-in GUI plugin's composer pill, rendered by the real window host.
Checklist