Skip to content

feat(core): add /goal with a client-side done check - #59437

Open
neriousy wants to merge 38 commits into
devfrom
client-done-check
Open

neriousy wants to merge 38 commits into
devfrom
client-done-check

Conversation

@neriousy

@neriousy neriousy commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Issue for this PR

Refs #27167

Alternative to #58508. It has the same idle hook, /goal and TUI, with these differences:

  • No new server event. feat(core): add /goal on top of a session idle hook #58508 adds session.settled. Here clients work out "done" from data they already sync (part 2).
  • No native flag on the compaction hook. 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 the idle hook.
  • The desktop goal pill is a built-in GUI plugin (packages/gui-plugins/src/goal), using the SDK's ComposerPill registry, instead of a custom capsule in the app.
  • Leaner. There are no storybook stories, no component test for the desktop capsule, and fewer tests.

Only one of the two should be merged.

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. Building it needed a reliable notion of "the Session is really idle", so the PR has five parts:

  1. Session idle plugin hook. Lets a plugin continue a Session at the point it would go idle, inside the same execution.
  2. "Done" notifications. Clients hold the "done" sound while a direct subagent is still running, with no new server event.
  3. /goal plugin. Built-in, and it uses only public plugin APIs.
  4. TUI. Shows the goal state under the prompt, with a dialog.
  5. Desktop. A built-in GUI plugin shows the goal as a composer pill, with a menu.

1. Session idle hook

The runner fires idle with { 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 example ctx.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.succeeded by queueing a new prompt. That has three problems:

  • Every round is a new busy period, with its own idle notification.
  • A user prompt can race the continuation.
  • A crash between rounds leaves nothing to resume.

Deciding inside the drain avoids all three.

  • background lists the background shells and subagents still running for the Session or any descendant. It comes from SessionBackground.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.
  • When it runs. Only when nothing is pending and the runner isn't mid-continuation. Never after an interruption or failure, or when a steer-scoped drain leaves queued input parked. It runs through restore(...), so a slow hook stays interruptible.
  • Moves and reloads. A move or Location reload landing at the idle boundary no longer skips the hook: stepped is carried through Moved / Reloaded.
  • Restarts and loop limits. Continuations reuse durable inbox admission, so restart recovery resumes a loop that was still going. The step allowance resets at each idle boundary, so a plugin that always admits input must bound its own loop. The README says so.
  • Types are added to both the Effect and Promise plugin 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.succeeded for X, they play "done" only if none of X's direct children is running:

data.session.family(id).some(
  (c) => c !== id && data.session.get(c)?.parentID === id && data.session.status(c) === "running",
)

status comes from store.session.active, which is set from execution.started and succeeded/failed/interrupted.

  • Only direct children count. A grandchild reports to its own parent, so waiting on it could mean no sound at all.
  • Running shells don't count, or a Session with a dev server running would never play "done".
  • Failed and interrupted executions still notify right away. In the TUI the check sits in the succeeded handler only, and subagent_done sounds are unchanged.
  • Desktop: 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.settled is the fallback if this turns out to be too fragile.

3. /goal

packages/core/src/plugin/goal.ts. The design draws on the harness notes in specs/goal/. The whole goal runs as one execution: each continuation is admitted from the idle hook, 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" notification
Loading
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
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 the Session's own background shells run, and never for long-running servers). An unusable answer, including wait when 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.
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. 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 sends the kickoff as the user's message instead, because only a prompt carries attachments. An untitled Session 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 }. 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 one of the Session's own background subagents runs, the reviewer isn't asked. The Session's own background shells are listed for the reviewer instead of blocking, so a dev server can't stall the goal. Work owned by descendants is ignored, because it never wakes this Session.
Compaction The goal lives in metadata and every continuation restates it. After a compaction, the context hook adds a short reminder of the objective, which isn't saved, until a kickoff or continuation is in view again. Native compactions included.
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 If nothing has run for 30 minutes after an execution ended (a hung subagent, a shell that never exits), 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 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.
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. opencode run has no slash commands, so /goal isn'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| Plugin
Loading
  • Client store. packages/client/src/solid/data.ts ignored session.metadata.updated, so metadata changed only on a full re-read. It now replaces the Session's metadata from the event. It uses reconcile, so dropped keys go away.
  • Goal status (GoalStatus in feature-plugins/prompt/goal.tsx, rendered by prompt/footer.tsx). A short item under the prompt beside the subagent/shell counts: ◎ goal, ◎ goal paused or ◎ goal met.
    • It shows only the state; the objective is in the dialog.
    • Active is muted, paused uses the warning colour and met the success colour.
    • There's no round counter.
  • Dialog. Clicking the status opens the objective, the status and the last reason, with p pause, r resume and x clear depending on the status.
  • Timeline. The kickoff notice uses ◎.

5. Desktop

A built-in GUI plugin, packages/gui-plugins/src/goal, listed in src/renderer.ts and src/main.ts like the other built-ins. It contributes one ComposerPill, so the host renders it in the composer lane next to the activity and todo pills. The app itself has no goal code.

  • Pill. Goal, Goal paused (warning tone) or Goal met (success tone).
  • Menu. The objective as its heading, then the actions that apply in one row: Pause, Resume and Clear. They call session.command with /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 /goal returns it.
  • SDK and host. ComposerPillView gains actions (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 long title made the menu heading run past the window, so the host now wraps it to three lines and truncates. Both are documented in the SDK, and plugin-host.spec.ts covers actions.
  • State. Read from the session's metadata in the client store with SessionGoal.fromMetadata, so a goal copied from a parent isn't shown. The host shows no pills in a subagent's session.
  • Strings. The plugin's own i18n/en.ts.

Not in this PR

  • A configurable reviewer model. It currently uses the Session's model.
  • A token budget.
  • Check-ins during long waits.
  • toolChoice: "none" for session.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):

  • Input admitted from the hook runs a second request in the same execution, for both resume: false and resume: true.
  • The hook fires only once the Session would go idle, not between tool steps.
  • background holds the Session's own and its descendants' background work, and not another Session's.
  • A background notice re-runs the hook.
  • A move at the idle boundary still fires the hook once at the destination.

Done notifications (the TUI's notifications.test.ts):

  • A parent succeeds while a child runs: no sound.
  • The last child goes idle, then the parent succeeds: one sound.
  • A running grandchild doesn't hold the parent back.

/goal (packages/core/test/plugin/goal.test.ts, 14 full-stack scenarios against TestLLM, plus goal-watchdog.test.ts):

Scenario Expectation
continue, then done One execution. Kickoff is a "Goal: …" notice. The continuation is hidden and carries the reason and next step. Complete after 1 round.
same reason repeated / rounds without tool calls Paused: "Not making progress: …"
tool calls and a new reason each round Keeps going
two unusable verdicts Paused
background subagent running Reviewer not asked until the subagent's notice wakes the Session
grandchild subagent or a subagent's shell running Reviewer still asked; wait isn't offered and counts as unusable
user stops the Session Paused, "Stopped by the user"
goal from an earlier process (at idle and at startup) Paused, not continued
child created while the goal is active Copied goal ignored
/goal pause during review / after the loop decided The user's pause wins
compaction while active The next request carries the unsaved reminder, placed before the user's prompt
reviewer overflows once / twice Compacts and completes / pauses
idle past 30 minutes (TestClock) Paused; a new execution cancels it
pause / resume / clear Fresh budget on resume; clear keeps other metadata keys

Each guard fails its test when removed.

Clients:

  • solid-data.test.ts: a metadata update that drops a key removes it.
  • prompt-footer.test.tsx:
    • the footer shows ◎ goal muted, with no objective or round counter;
    • it opens the dialog on click and turns warning when paused;
    • it ignores a goal copied from a parent.
  • I drove the real TUI against this branch with 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 check passes.
  • lint:changed is clean.
  • The affected core, client, TUI and app suites pass.

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

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.

Recording of the goal loop in the TUI

Start: ◎ goal under the prompt, at the right.
Goal status after /goal
Long output: the status stays and the continuation adds no row.
Goal status while output scrolls
Met.
Goal met
Click the status: objective, status, reason and actions.
Goal dialog
Stopped with Esc: the goal pauses.
Goal paused

Desktop. The built-in GUI plugin's composer pill, rendered by the real window host.

Active
Goal pill with its menu open
Paused
Goal paused with its menu open
Met
Goal met with its menu open
Long objective wraps to three lines
Goal menu with a long objective
Light theme
Goal pill with its menu open, light theme

Checklist

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

@neriousy
neriousy changed the base branch from session-idle-hook to dev October 26, 2026 23:36
@neriousy neriousy changed the title fix(app,tui): hold the done sound while a direct subagent is running feat(core): add /goal with a client-side done check Oct 26, 2026
@neriousy
neriousy marked this pull request as draft October 27, 2026 12:33
@neriousy
neriousy marked this pull request as ready for review October 27, 2026 13:11

This branch has not been deployed

No deployments
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