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
@@ -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';