feat: session goals - server-driven goal loop with independent small-model audit (#2148)

Arm the target button in the composer and the next prompt becomes a goal:
the server keeps the session working toward it (idle tick -> small-model
audit -> continuation) until the objective is verifiably complete, blocked,
or out of budget — even with the UI closed.

Server (packages/web/server/lib/session-goal):
- event-driven loop on the global SSE hub; goal state lives in
  session.metadata.openchamber.goal (merge-safe patches, stale-write guard
  by goal id), so it survives restarts and syncs to every client for free
- the small-model audit (objective + last assistant turn only, language
  pinned to the objective) is the sole termination authority; blocked needs
  3 consecutive verdicts, audit outages tolerate one unaudited continuation
  then stop the goal as resumable-blocked
- hard stops: optional token budget, auto-continuation cap (Resume grants a
  fresh allowance), turn errors; user abort pauses the goal instead of
  blocking it, and resuming over an aborted tail nudges immediately
- token accounting as a snapshot of the latest turn (input + cache.read +
  output), goal-relative via a creation baseline and segmented across
  compactions; a compaction summary skips the audit and continues
- continuations reuse the session's own provider/model/agent/variant

UI:
- three-mode target button (arm / disarm / manage dialog), informational
  goal strip with inline pause/resume and an Evaluating indicator, sidebar
  state glyph, objective length counter (2000-char server clamp),
  read-only completed goals
- goal entry points: composer (sessions and drafts), start-new-session-
  from-answer dialog, plan implement dialog (plan content becomes the
  objective), scheduled tasks (Run as goal + budget)
- Settings -> Chat -> Goal: feature toggle + default token budget with
  three-layer parity (web server, client persistence, VS Code bridge);
  VS Code renders goal state but hides the entry points (the loop runs in
  the web server only)

Notifications: per-turn "ready" notifications are suppressed while a goal
is active; settling sends one final notification (desktop, web-push, APNs
generic titles with the session name as body) honoring the completion
toggle. Error/question/permission notifications are untouched.

Docs: user guide (session-goals) in all 9 locales + sidebar entry,
scheduled-tasks cross-reference, server module DOCUMENTATION.md.
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 01:23:22 +03:00
committed by GitHub
parent 82c039117a
commit bb45164ae8
73 changed files with 3330 additions and 29 deletions
@@ -100,6 +100,8 @@ import {
} from './attachmentCitations';
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
import { SessionGoalButton, SessionGoalObjectiveCounter } from '@/components/chat/SessionGoalButton';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -4904,6 +4906,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
>
{isMobile && !mobileComposerExpanded ? (
<div className="flex flex-col">
<SessionGoalRow
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
className="mb-1.5"
/>
<SessionSuggestionChip
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
@@ -4986,6 +4993,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
</div>
) : (
<>
<SessionGoalRow
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
className="mb-1.5"
/>
<SessionSuggestionChip
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
@@ -5313,6 +5325,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
/>
<SessionGoalButton
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<SessionGoalObjectiveCounter length={message.length} />
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 flex-shrink-0">
@@ -5382,6 +5402,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
withTooltip
/>
<SessionGoalButton
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
withTooltip
/>
<SessionGoalObjectiveCounter length={message.length} />
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
@@ -0,0 +1,140 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface SessionGoalButtonProps {
sessionId: string | null;
directory?: string;
/** Session draft is open — the goal arms for the session the draft creates. */
draftOpen?: boolean;
footerIconButtonClass: string;
iconSizeClass: string;
withTooltip?: boolean;
}
// Composer target button — the goal switch. With no live goal one tap arms
// goal mode (the next sent prompt becomes the objective; works on drafts
// too) and a second tap disarms. While a goal is live the target stays lit
// (info while running, success when complete, error when blocked / out of
// budget) and tapping opens the manage dialog.
export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
sessionId,
directory,
draftOpen = false,
footerIconButtonClass,
iconSizeClass,
withTooltip = false,
}) => {
const { t } = useI18n();
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
const armed = useSessionGoalArmStore((state) => state.armed);
const setArmed = useSessionGoalArmStore((state) => state.setArmed);
const [dialogOpen, setDialogOpen] = React.useState(false);
// The goal loop runs in the web server; the VS Code extension only renders
// goal state. Arming a goal there would create one nothing drives, so the
// entry point is hidden entirely.
if (isVSCodeRuntime() || !enabled || (!sessionId && !draftOpen)) {
return null;
}
// A settled goal no longer drives the loop — the button goes back to being
// an arm switch, while still tinting with the outcome color.
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
const isEngaged = armed || Boolean(liveGoal);
const colorClass = (() => {
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
return '';
})();
const label = goal
? t('chat.goal.button.manageAria')
: (armed ? t('chat.goal.button.disarmAria') : t('chat.goal.button.armAria'));
// Any existing goal (live or completed) opens the manage dialog — a
// completed goal must be removed there before a new one can be armed.
const handleClick = () => {
if (goal) {
setDialogOpen(true);
return;
}
setArmed(!armed);
};
const button = (
<button
type="button"
className={cn(footerIconButtonClass, colorClass)}
onClick={handleClick}
aria-label={label}
aria-pressed={isEngaged}
{...(withTooltip ? {} : { title: label })}
>
{isEngaged || goal ? (
<Icon name="target-fill" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
) : (
<Icon name="target" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
)}
</button>
);
return (
<>
{withTooltip ? (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>{label}</TooltipContent>
</Tooltip>
) : button}
{sessionId ? (
<SessionGoalDialog open={dialogOpen} onOpenChange={setDialogOpen} sessionId={sessionId} directory={directory} />
) : null}
</>
);
});
SessionGoalButton.displayName = 'SessionGoalButton';
interface SessionGoalObjectiveCounterProps {
/** Current composer text length — the armed message becomes the objective. */
length: number;
}
// Tiny hot-path leaf next to the target button: while goal mode is armed the
// typed message becomes the objective, which the server clamps to 2000
// chars — surface that limit during typing instead of truncating silently.
// Renders null when not armed, so normal typing shows nothing.
export const SessionGoalObjectiveCounter: React.FC<SessionGoalObjectiveCounterProps> = React.memo(({ length }) => {
const { t } = useI18n();
const armed = useSessionGoalArmStore((state) => state.armed);
if (!armed || length === 0) {
return null;
}
const over = length > SESSION_GOAL_OBJECTIVE_CHAR_LIMIT;
return (
<span
className={cn(
'flex-shrink-0 self-center typography-micro tabular-nums',
over ? 'text-[var(--status-error)]' : 'text-muted-foreground/70',
)}
aria-label={t('chat.goal.counter.aria')}
title={t('chat.goal.counter.aria')}
>
{length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
</span>
);
});
SessionGoalObjectiveCounter.displayName = 'SessionGoalObjectiveCounter';
@@ -0,0 +1,189 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { NumberInput } from '@/components/ui/number-input';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import {
formatGoalTokens,
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
} from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions';
import { useI18n } from '@/lib/i18n';
interface SessionGoalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string;
directory?: string;
}
// Create/manage dialog for the session goal: objective + optional token
// budget on creation; status, usage, latest audit note and lifecycle actions
// (pause/resume/complete/clear) once a goal exists.
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
const { t } = useI18n();
const { goal } = useSessionGoal(sessionId, directory);
const [objective, setObjective] = React.useState('');
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
const [tokenBudget, setTokenBudget] = React.useState<number>(200_000);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setObjective(goal?.objective ?? '');
setBudgetEnabled(Boolean(goal?.tokenBudget));
setTokenBudget(goal?.tokenBudget ?? 200_000);
// Seed the form only when the dialog opens; live goal updates while it is
// open must not clobber the user's edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
setBusy(true);
try {
await action();
if (closeAfter) onOpenChange(false);
} catch (error) {
console.warn('[session-goal] action failed:', error);
toast.error(t('chat.goal.toast.actionFailed'));
} finally {
setBusy(false);
}
}, [onOpenChange, t]);
const trimmedObjective = objective.trim();
const objectiveChanged = trimmedObjective !== (goal?.objective ?? '');
const budgetValue = budgetEnabled ? tokenBudget : null;
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
// A completed goal is read-only: remove it and arm a new one instead of
// "saving" over the outcome (re-saving used to spawn a fresh active goal
// that the auditor instantly re-completed — a confusing status flash).
const isCompleted = goal?.status === 'complete';
const canSave = !isCompleted && trimmedObjective.length > 0 && (!goal || objectiveChanged || budgetChanged);
const handleSave = () => run(
() => setSessionGoal(sessionId, directory, { objective: trimmedObjective, tokenBudget: budgetValue }, goal),
true,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{goal && (
<div className="space-y-1 p-2 rounded-lg" style={{ backgroundColor: 'var(--surface-elevated)' }}>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
<span className="typography-ui-label text-foreground">{t(sessionGoalStatusLabelKey[goal.status] as never)}</span>
<span className="typography-meta text-muted-foreground tabular-nums">
{goal.tokenBudget
? t('chat.goal.usage.tokensWithBudget', {
used: formatGoalTokens(goal.tokensUsed),
budget: formatGoalTokens(goal.tokenBudget),
})
: t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) })}
{' · '}
{t('chat.goal.usage.turns', { turns: goal.turnsUsed })}
</span>
</div>
{goal.note ? (
<p className="typography-meta text-muted-foreground">{goal.note}</p>
) : null}
{/* Only failure states carry a reason worth reading; outcomes
like "verified by audit" are noise next to the status dot. */}
{goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? (
<p className="typography-meta text-muted-foreground/70">{goal.statusReason}</p>
) : null}
</div>
)}
{isCompleted ? (
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{goal.objective}</p>
) : (
<>
<div className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.objectiveLabel')}</span>
<span className="typography-micro tabular-nums text-muted-foreground/70" aria-label={t('chat.goal.counter.aria')}>
{objective.length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
</span>
</div>
<Textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
placeholder={t('chat.goal.dialog.objectivePlaceholder')}
maxLength={SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
rows={4}
/>
</div>
<div className="flex items-center gap-8">
<div
className="flex cursor-pointer items-center gap-2"
role="button"
tabIndex={0}
aria-pressed={budgetEnabled}
onClick={() => setBudgetEnabled((value) => !value)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setBudgetEnabled((value) => !value);
}
}}
>
<Checkbox
checked={budgetEnabled}
onChange={setBudgetEnabled}
ariaLabel={t('chat.goal.dialog.budgetLabel')}
/>
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.budgetLabel')}</span>
</div>
{budgetEnabled && (
<NumberInput
value={tokenBudget}
onValueChange={(value) => setTokenBudget(typeof value === 'number' && value > 0 ? Math.floor(value) : 1000)}
min={1000}
max={100_000_000}
step={50_000}
/>
)}
</div>
</>
)}
<div className="flex items-center gap-2 pt-1">
{goal && (
<Button variant="destructive" size="sm" disabled={busy} onClick={() => run(() => clearSessionGoal(sessionId, directory), true)}>
{t('chat.goal.action.clear')}
</Button>
)}
<div className="flex flex-1 items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
{t('chat.goal.action.cancel')}
</Button>
{!isCompleted && (
<Button size="sm" disabled={busy || !canSave} onClick={handleSave}>
{goal ? t('chat.goal.action.save') : t('chat.goal.action.start')}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,108 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useSessionStatus } from '@/sync/sync-context';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface SessionGoalRowProps {
sessionId: string | null;
directory?: string;
className?: string;
}
// Compact goal strip near the composer: informational only — status dot,
// objective (or the latest audit note), token usage — plus an inline
// pause/resume action. The manage dialog opens from the composer target
// button, not from here.
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
const { t } = useI18n();
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
const [busy, setBusy] = React.useState(false);
const handleToggleStatus = React.useCallback(async (nextStatus: 'active' | 'paused') => {
if (!sessionId || busy) return;
setBusy(true);
try {
await setSessionGoalStatus(sessionId, directory, nextStatus);
} catch (error) {
console.warn('[session-goal] status change failed:', error);
toast.error(t('chat.goal.toast.actionFailed'));
} finally {
setBusy(false);
}
}, [sessionId, directory, busy, t]);
if (!sessionId || !enabled || !goal) {
return null;
}
// Accounting only lands on idle ticks — hide the counter until there is a
// real number (or a budget worth tracking against) instead of showing "0".
const usage = goal.tokenBudget
? t('chat.goal.usage.tokensWithBudget', {
used: formatGoalTokens(goal.tokensUsed),
budget: formatGoalTokens(goal.tokenBudget),
})
: (goal.tokensUsed > 0 ? t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) }) : null);
const pauseResume = goal.status === 'active'
? { icon: 'pause' as const, labelKey: 'chat.goal.action.pause' as const, next: 'paused' as const }
: (goal.status === 'paused' || goal.status === 'blocked' || goal.status === 'budgetLimited'
? { icon: 'play' as const, labelKey: 'chat.goal.action.resume' as const, next: 'active' as const }
: null);
return (
<div
className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-lg border px-2 py-1',
'border-[var(--interactive-border)]',
className,
)}
aria-label={t('chat.goal.row.aria')}
title={goal.objective}
>
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
{goal.note || goal.objective}
</span>
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
// The agent stopped but the goal is still active: the server is
// sitting out the quiet window and running the audit — show that
// instead of a static "Active" that looks stuck.
<span className="flex flex-shrink-0 items-center gap-1 typography-meta text-muted-foreground">
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
{t('chat.goal.status.evaluating')}
</span>
) : (
<span className="flex-shrink-0 typography-meta text-muted-foreground">
{t(sessionGoalStatusLabelKey[goal.status] as never)}
</span>
)}
{usage ? (
<span className="flex-shrink-0 typography-meta tabular-nums text-muted-foreground/70">
{usage}
</span>
) : null}
{pauseResume ? (
<button
type="button"
onClick={() => void handleToggleStatus(pauseResume.next)}
disabled={busy}
className="flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-1 py-0.5 typography-meta text-muted-foreground hover:bg-[var(--interactive-hover)] hover:text-foreground disabled:opacity-50"
aria-label={t(pauseResume.labelKey)}
>
<Icon name={pauseResume.icon} className="h-3 w-3" aria-hidden="true" />
<span>{t(pauseResume.labelKey)}</span>
</button>
) : null}
</div>
);
});
SessionGoalRow.displayName = 'SessionGoalRow';