diff --git a/app/scripts/e2e-web-session.sh b/app/scripts/e2e-web-session.sh
index 3e1488c30..51aacae03 100755
--- a/app/scripts/e2e-web-session.sh
+++ b/app/scripts/e2e-web-session.sh
@@ -102,6 +102,12 @@ coding_provider = "e2e:e2e-mock-model"
[update]
enabled = false
+[context]
+# Deterministic e2e specs script the mock-LLM call sequence exactly; the
+# default-on first-turn "super context" scout adds an extra agentic call that
+# would perturb those sequences. No spec exercises super context, so disable it.
+super_context_enabled = false
+
[[cloud_providers]]
id = "p_e2e_mock"
slug = "e2e"
diff --git a/app/src/components/chat/ChatComposer.tsx b/app/src/components/chat/ChatComposer.tsx
index 10f20d827..b3ce89b73 100644
--- a/app/src/components/chat/ChatComposer.tsx
+++ b/app/src/components/chat/ChatComposer.tsx
@@ -39,6 +39,14 @@ export interface ChatComposerProps {
* attach button, hidden file input, and preview strip are not rendered.
*/
attachmentsEnabled: boolean;
+ /**
+ * Optional nodes stacked above the input box but *outside* its focus
+ * highlight — e.g. the queued-follow-ups strip and the thread-goal editor.
+ * They render within the overall composer component (so they move with it)
+ * yet are not wrapped by the blue focus-within ring/border of the input box.
+ * Entries that render `null` contribute nothing.
+ */
+ headerSlots?: React.ReactNode[];
}
/**
@@ -66,6 +74,7 @@ export default function ChatComposer({
maxAttachments,
allowedMimeTypes,
attachmentsEnabled,
+ headerSlots = [],
}: ChatComposerProps) {
const { t } = useT();
@@ -93,155 +102,163 @@ export default function ChatComposer({
}, [inputValue, textInputRef]);
return (
-
- {/* Hidden file input for attachment (gated — see attachmentsEnabled). */}
- {attachmentsEnabled && (
-
{
- void onAttachFiles(e.target.files);
- e.target.value = '';
- }}
- />
- )}
+
+ {/* Header stack (e.g. queued follow-ups, thread-goal editor): rendered
+ above the input box and OUTSIDE its blue focus highlight, but still
+ within the overall composer component so they move as one unit. */}
+ {headerSlots}
- {/* Attachment preview strip */}
- {attachmentsEnabled && attachments.length > 0 && (
-
- )}
-
- {/* Single row: [+] textarea [mic] [send] */}
-
- {/* Attach button */}
+ {/* The input box — only this carries the focus-within highlight. */}
+
+ {/* Hidden file input for attachment (gated — see attachmentsEnabled). */}
{attachmentsEnabled && (
+
{
+ void onAttachFiles(e.target.files);
+ e.target.value = '';
+ }}
+ />
+ )}
+
+ {/* Attachment preview strip */}
+ {attachmentsEnabled && attachments.length > 0 && (
+
+ )}
+
+ {/* Single row: [+] textarea [mic] [send] */}
+
+ {/* Attach button */}
+ {attachmentsEnabled && (
+
fileInputRef.current?.click()}
+ disabled={
+ composerInteractionBlocked || isSending || attachments.length >= maxAttachments
+ }
+ className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
+
+
+
+
+ )}
+
+ {/* Textarea with ghost completion */}
+
+
+ {inputValue}
+
+ {inlineCompletionSuffix}
+
+
+
+
+ {/* Voice mode */}
fileInputRef.current?.click()}
- disabled={
- composerInteractionBlocked || isSending || attachments.length >= maxAttachments
- }
+ data-analytics-id="chat-composer-voice-mode"
+ aria-label={t('composer.voiceMode')}
+ title={t('composer.voiceMode')}
+ onClick={onSwitchToMicCloud}
+ disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
-
+
+
- )}
- {/* Textarea with ghost completion */}
-
-
- {inputValue}
-
- {inlineCompletionSuffix}
-
-
-
-
- {/* Voice mode */}
-
-
-
-
-
-
-
- {/* Send button */}
-
{
- void onSend();
- }}
- disabled={!hasContent || composerLocked}
- className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
- {showSendingSpinner ? (
-
-
-
-
- ) : (
-
-
-
- )}
-
);
diff --git a/app/src/components/chat/QueuedFollowups.tsx b/app/src/components/chat/QueuedFollowups.tsx
index 445638c48..2cfb2c62a 100644
--- a/app/src/components/chat/QueuedFollowups.tsx
+++ b/app/src/components/chat/QueuedFollowups.tsx
@@ -22,7 +22,7 @@ export default function QueuedFollowups({ items, onClear }: QueuedFollowupsProps
return (
+ className="mb-2 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
{t('chat.queuedFollowups.label')} · {items.length}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index c53254001..9e1a16f67 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -2984,6 +2984,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'نقل لليمين',
'conversations.taskKanban.title': 'المهام',
'conversations.threadTodo.title': 'الخطة',
+ 'conversations.threadGoal.label': 'الهدف',
+ 'conversations.threadGoal.setCta': 'تحديد هدف',
+ 'conversations.threadGoal.placeholder': 'ما الذي يجب أن تحققه هذه المحادثة؟',
+ 'conversations.threadGoal.save': 'حفظ',
+ 'conversations.threadGoal.cancel': 'إلغاء',
+ 'conversations.threadGoal.edit': 'تعديل الهدف',
+ 'conversations.threadGoal.clear': 'مسح الهدف',
+ 'conversations.threadGoal.complete': 'وضع علامة كمكتمل',
+ 'conversations.threadGoal.pause': 'إيقاف مؤقت',
+ 'conversations.threadGoal.resume': 'استئناف',
+ 'conversations.threadGoal.tokensSuffix': 'رمز',
+ 'conversations.threadGoal.status.active': 'نشط',
+ 'conversations.threadGoal.status.paused': 'متوقف مؤقتًا',
+ 'conversations.threadGoal.status.budget_limited': 'تم بلوغ الحد',
+ 'conversations.threadGoal.status.complete': 'مكتمل',
'conversations.taskKanban.approval.default': 'التقصير',
'conversations.taskKanban.approval.notRequired': 'غير مطلوب',
'conversations.taskKanban.approval.notRequiredBadge': 'عدم الموافقة',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index b2db3299c..1bee18079 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -3050,6 +3050,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'ডানে সরান',
'conversations.taskKanban.title': 'টাস্ক',
'conversations.threadTodo.title': 'পরিকল্পনা',
+ 'conversations.threadGoal.label': 'লক্ষ্য',
+ 'conversations.threadGoal.setCta': 'লক্ষ্য নির্ধারণ',
+ 'conversations.threadGoal.placeholder': 'এই থ্রেডে কী অর্জন করতে হবে?',
+ 'conversations.threadGoal.save': 'সংরক্ষণ',
+ 'conversations.threadGoal.cancel': 'বাতিল',
+ 'conversations.threadGoal.edit': 'লক্ষ্য সম্পাদনা',
+ 'conversations.threadGoal.clear': 'লক্ষ্য মুছুন',
+ 'conversations.threadGoal.complete': 'সম্পন্ন হিসেবে চিহ্নিত করুন',
+ 'conversations.threadGoal.pause': 'বিরতি',
+ 'conversations.threadGoal.resume': 'পুনরায় শুরু',
+ 'conversations.threadGoal.tokensSuffix': 'টোকেন',
+ 'conversations.threadGoal.status.active': 'সক্রিয়',
+ 'conversations.threadGoal.status.paused': 'বিরতিতে',
+ 'conversations.threadGoal.status.budget_limited': 'বাজেট শেষ',
+ 'conversations.threadGoal.status.complete': 'সম্পন্ন',
'conversations.taskKanban.approval.default': 'ডিফল্ট',
'conversations.taskKanban.approval.notRequired': 'প্রয়োজন নেই',
'conversations.taskKanban.approval.notRequiredBadge': 'অনুমোদন করা হবে না',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 6845e43e6..886cae97a 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -3121,6 +3121,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Bewege dich nach rechts',
'conversations.taskKanban.title': 'Aufgaben',
'conversations.threadTodo.title': 'Plan',
+ 'conversations.threadGoal.label': 'Ziel',
+ 'conversations.threadGoal.setCta': 'Ziel festlegen',
+ 'conversations.threadGoal.placeholder': 'Was soll dieser Thread erreichen?',
+ 'conversations.threadGoal.save': 'Speichern',
+ 'conversations.threadGoal.cancel': 'Abbrechen',
+ 'conversations.threadGoal.edit': 'Ziel bearbeiten',
+ 'conversations.threadGoal.clear': 'Ziel löschen',
+ 'conversations.threadGoal.complete': 'Als erledigt markieren',
+ 'conversations.threadGoal.pause': 'Pausieren',
+ 'conversations.threadGoal.resume': 'Fortsetzen',
+ 'conversations.threadGoal.tokensSuffix': 'Tokens',
+ 'conversations.threadGoal.status.active': 'Aktiv',
+ 'conversations.threadGoal.status.paused': 'Pausiert',
+ 'conversations.threadGoal.status.budget_limited': 'Budget erreicht',
+ 'conversations.threadGoal.status.complete': 'Abgeschlossen',
'conversations.taskKanban.approval.default': 'Standard',
'conversations.taskKanban.approval.notRequired': 'Nicht erforderlich',
'conversations.taskKanban.approval.notRequiredBadge': 'keine Genehmigung',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index d8e9b68a4..dfc84facc 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -3585,6 +3585,22 @@ const en: TranslationMap = {
'conversations.taskKanban.moveRight': 'Move right',
'conversations.taskKanban.title': 'Tasks',
'conversations.threadTodo.title': 'Plan',
+ // Thread-level goal chip (Codex-style per-thread completion contract).
+ 'conversations.threadGoal.label': 'Goal',
+ 'conversations.threadGoal.setCta': 'Set goal',
+ 'conversations.threadGoal.placeholder': 'What should this thread accomplish?',
+ 'conversations.threadGoal.save': 'Save',
+ 'conversations.threadGoal.cancel': 'Cancel',
+ 'conversations.threadGoal.edit': 'Edit goal',
+ 'conversations.threadGoal.clear': 'Clear goal',
+ 'conversations.threadGoal.complete': 'Mark complete',
+ 'conversations.threadGoal.pause': 'Pause',
+ 'conversations.threadGoal.resume': 'Resume',
+ 'conversations.threadGoal.tokensSuffix': 'tokens',
+ 'conversations.threadGoal.status.active': 'Active',
+ 'conversations.threadGoal.status.paused': 'Paused',
+ 'conversations.threadGoal.status.budget_limited': 'Budget reached',
+ 'conversations.threadGoal.status.complete': 'Complete',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 78c577556..a5eb55d0f 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -3099,6 +3099,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Mover a la derecha',
'conversations.taskKanban.title': 'Tareas',
'conversations.threadTodo.title': 'Plan',
+ 'conversations.threadGoal.label': 'Objetivo',
+ 'conversations.threadGoal.setCta': 'Definir objetivo',
+ 'conversations.threadGoal.placeholder': '¿Qué debe lograr este hilo?',
+ 'conversations.threadGoal.save': 'Guardar',
+ 'conversations.threadGoal.cancel': 'Cancelar',
+ 'conversations.threadGoal.edit': 'Editar objetivo',
+ 'conversations.threadGoal.clear': 'Borrar objetivo',
+ 'conversations.threadGoal.complete': 'Marcar como completado',
+ 'conversations.threadGoal.pause': 'Pausar',
+ 'conversations.threadGoal.resume': 'Reanudar',
+ 'conversations.threadGoal.tokensSuffix': 'tokens',
+ 'conversations.threadGoal.status.active': 'Activo',
+ 'conversations.threadGoal.status.paused': 'En pausa',
+ 'conversations.threadGoal.status.budget_limited': 'Presupuesto agotado',
+ 'conversations.threadGoal.status.complete': 'Completado',
'conversations.taskKanban.approval.default': 'Por defecto',
'conversations.taskKanban.approval.notRequired': 'No requerido',
'conversations.taskKanban.approval.notRequiredBadge': 'sin aprobación',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 6027edac5..2be47ecaa 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -3114,6 +3114,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Déplacer à droite',
'conversations.taskKanban.title': 'Tâches',
'conversations.threadTodo.title': 'Plan',
+ 'conversations.threadGoal.label': 'Objectif',
+ 'conversations.threadGoal.setCta': 'Définir un objectif',
+ 'conversations.threadGoal.placeholder': 'Que doit accomplir ce fil ?',
+ 'conversations.threadGoal.save': 'Enregistrer',
+ 'conversations.threadGoal.cancel': 'Annuler',
+ 'conversations.threadGoal.edit': 'Modifier l’objectif',
+ 'conversations.threadGoal.clear': 'Effacer l’objectif',
+ 'conversations.threadGoal.complete': 'Marquer comme terminé',
+ 'conversations.threadGoal.pause': 'Suspendre',
+ 'conversations.threadGoal.resume': 'Reprendre',
+ 'conversations.threadGoal.tokensSuffix': 'jetons',
+ 'conversations.threadGoal.status.active': 'Actif',
+ 'conversations.threadGoal.status.paused': 'En pause',
+ 'conversations.threadGoal.status.budget_limited': 'Budget atteint',
+ 'conversations.threadGoal.status.complete': 'Terminé',
'conversations.taskKanban.approval.default': 'Par défaut',
'conversations.taskKanban.approval.notRequired': 'Non requis',
'conversations.taskKanban.approval.notRequiredBadge': 'aucune approbation',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 4edb63ddb..1215abcf5 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -3050,6 +3050,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'दाएं ले जाएं',
'conversations.taskKanban.title': 'टास्क',
'conversations.threadTodo.title': 'योजना',
+ 'conversations.threadGoal.label': 'लक्ष्य',
+ 'conversations.threadGoal.setCta': 'लक्ष्य सेट करें',
+ 'conversations.threadGoal.placeholder': 'इस थ्रेड को क्या हासिल करना चाहिए?',
+ 'conversations.threadGoal.save': 'सहेजें',
+ 'conversations.threadGoal.cancel': 'रद्द करें',
+ 'conversations.threadGoal.edit': 'लक्ष्य संपादित करें',
+ 'conversations.threadGoal.clear': 'लक्ष्य हटाएँ',
+ 'conversations.threadGoal.complete': 'पूर्ण के रूप में चिह्नित करें',
+ 'conversations.threadGoal.pause': 'रोकें',
+ 'conversations.threadGoal.resume': 'फिर शुरू करें',
+ 'conversations.threadGoal.tokensSuffix': 'टोकन',
+ 'conversations.threadGoal.status.active': 'सक्रिय',
+ 'conversations.threadGoal.status.paused': 'रुका हुआ',
+ 'conversations.threadGoal.status.budget_limited': 'बजट समाप्त',
+ 'conversations.threadGoal.status.complete': 'पूर्ण',
'conversations.taskKanban.approval.default': 'डिफ़ॉल्ट',
'conversations.taskKanban.approval.notRequired': 'आवश्यकता नहीं',
'conversations.taskKanban.approval.notRequiredBadge': 'कोई अनुमोदन नहीं',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 4786a9980..fc59d7924 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -3053,6 +3053,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Pindah ke kanan',
'conversations.taskKanban.title': 'Tugas',
'conversations.threadTodo.title': 'Rencana',
+ 'conversations.threadGoal.label': 'Tujuan',
+ 'conversations.threadGoal.setCta': 'Tetapkan tujuan',
+ 'conversations.threadGoal.placeholder': 'Apa yang harus dicapai utas ini?',
+ 'conversations.threadGoal.save': 'Simpan',
+ 'conversations.threadGoal.cancel': 'Batal',
+ 'conversations.threadGoal.edit': 'Edit tujuan',
+ 'conversations.threadGoal.clear': 'Hapus tujuan',
+ 'conversations.threadGoal.complete': 'Tandai selesai',
+ 'conversations.threadGoal.pause': 'Jeda',
+ 'conversations.threadGoal.resume': 'Lanjutkan',
+ 'conversations.threadGoal.tokensSuffix': 'token',
+ 'conversations.threadGoal.status.active': 'Aktif',
+ 'conversations.threadGoal.status.paused': 'Dijeda',
+ 'conversations.threadGoal.status.budget_limited': 'Anggaran tercapai',
+ 'conversations.threadGoal.status.complete': 'Selesai',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Tidak diperlukan',
'conversations.taskKanban.approval.notRequiredBadge': 'tidak ada persetujuan',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index ae088c5c5..a161dab49 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -3093,6 +3093,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Sposta a destra',
'conversations.taskKanban.title': 'Attività',
'conversations.threadTodo.title': 'Piano',
+ 'conversations.threadGoal.label': 'Obiettivo',
+ 'conversations.threadGoal.setCta': 'Imposta obiettivo',
+ 'conversations.threadGoal.placeholder': 'Cosa deve realizzare questa conversazione?',
+ 'conversations.threadGoal.save': 'Salva',
+ 'conversations.threadGoal.cancel': 'Annulla',
+ 'conversations.threadGoal.edit': 'Modifica obiettivo',
+ 'conversations.threadGoal.clear': 'Cancella obiettivo',
+ 'conversations.threadGoal.complete': 'Segna come completato',
+ 'conversations.threadGoal.pause': 'Sospendi',
+ 'conversations.threadGoal.resume': 'Riprendi',
+ 'conversations.threadGoal.tokensSuffix': 'token',
+ 'conversations.threadGoal.status.active': 'Attivo',
+ 'conversations.threadGoal.status.paused': 'In pausa',
+ 'conversations.threadGoal.status.budget_limited': 'Budget raggiunto',
+ 'conversations.threadGoal.status.complete': 'Completato',
'conversations.taskKanban.approval.default': 'Predefinito',
'conversations.taskKanban.approval.notRequired': 'Non richiesto',
'conversations.taskKanban.approval.notRequiredBadge': 'nessuna approvazione',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index f6452bfbe..83028b1ef 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -3023,6 +3023,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': '오른쪽으로 이동',
'conversations.taskKanban.title': '작업',
'conversations.threadTodo.title': '계획',
+ 'conversations.threadGoal.label': '목표',
+ 'conversations.threadGoal.setCta': '목표 설정',
+ 'conversations.threadGoal.placeholder': '이 스레드가 무엇을 달성해야 하나요?',
+ 'conversations.threadGoal.save': '저장',
+ 'conversations.threadGoal.cancel': '취소',
+ 'conversations.threadGoal.edit': '목표 편집',
+ 'conversations.threadGoal.clear': '목표 지우기',
+ 'conversations.threadGoal.complete': '완료로 표시',
+ 'conversations.threadGoal.pause': '일시 중지',
+ 'conversations.threadGoal.resume': '재개',
+ 'conversations.threadGoal.tokensSuffix': '토큰',
+ 'conversations.threadGoal.status.active': '활성',
+ 'conversations.threadGoal.status.paused': '일시 중지됨',
+ 'conversations.threadGoal.status.budget_limited': '예산 도달',
+ 'conversations.threadGoal.status.complete': '완료',
'conversations.taskKanban.approval.default': '기본값',
'conversations.taskKanban.approval.notRequired': '필요 없음',
'conversations.taskKanban.approval.notRequiredBadge': '승인 없음',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 8ff14225b..ac4ff09f5 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -3080,6 +3080,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Przesuń w prawo',
'conversations.taskKanban.title': 'Zadania',
'conversations.threadTodo.title': 'Plan',
+ 'conversations.threadGoal.label': 'Cel',
+ 'conversations.threadGoal.setCta': 'Ustaw cel',
+ 'conversations.threadGoal.placeholder': 'Co ma osiągnąć ten wątek?',
+ 'conversations.threadGoal.save': 'Zapisz',
+ 'conversations.threadGoal.cancel': 'Anuluj',
+ 'conversations.threadGoal.edit': 'Edytuj cel',
+ 'conversations.threadGoal.clear': 'Wyczyść cel',
+ 'conversations.threadGoal.complete': 'Oznacz jako ukończone',
+ 'conversations.threadGoal.pause': 'Wstrzymaj',
+ 'conversations.threadGoal.resume': 'Wznów',
+ 'conversations.threadGoal.tokensSuffix': 'tokeny',
+ 'conversations.threadGoal.status.active': 'Aktywny',
+ 'conversations.threadGoal.status.paused': 'Wstrzymany',
+ 'conversations.threadGoal.status.budget_limited': 'Budżet wyczerpany',
+ 'conversations.threadGoal.status.complete': 'Ukończony',
'conversations.taskKanban.approval.default': 'Domyślne',
'conversations.taskKanban.approval.notRequired': 'Niewymagane',
'conversations.taskKanban.approval.notRequiredBadge': 'bez zatwierdzenia',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 2d8626533..a04a15c11 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -3098,6 +3098,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Mover para direita',
'conversations.taskKanban.title': 'Tarefas',
'conversations.threadTodo.title': 'Plano',
+ 'conversations.threadGoal.label': 'Objetivo',
+ 'conversations.threadGoal.setCta': 'Definir objetivo',
+ 'conversations.threadGoal.placeholder': 'O que esta conversa deve realizar?',
+ 'conversations.threadGoal.save': 'Salvar',
+ 'conversations.threadGoal.cancel': 'Cancelar',
+ 'conversations.threadGoal.edit': 'Editar objetivo',
+ 'conversations.threadGoal.clear': 'Limpar objetivo',
+ 'conversations.threadGoal.complete': 'Marcar como concluído',
+ 'conversations.threadGoal.pause': 'Pausar',
+ 'conversations.threadGoal.resume': 'Retomar',
+ 'conversations.threadGoal.tokensSuffix': 'tokens',
+ 'conversations.threadGoal.status.active': 'Ativo',
+ 'conversations.threadGoal.status.paused': 'Pausado',
+ 'conversations.threadGoal.status.budget_limited': 'Orçamento atingido',
+ 'conversations.threadGoal.status.complete': 'Concluído',
'conversations.taskKanban.approval.default': 'Padrão',
'conversations.taskKanban.approval.notRequired': 'Não obrigatório',
'conversations.taskKanban.approval.notRequiredBadge': 'sem aprovação',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index e94ca0274..7fb7b797a 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -3072,6 +3072,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': 'Переместить вправо',
'conversations.taskKanban.title': 'Задачи',
'conversations.threadTodo.title': 'План',
+ 'conversations.threadGoal.label': 'Цель',
+ 'conversations.threadGoal.setCta': 'Задать цель',
+ 'conversations.threadGoal.placeholder': 'Чего должен достичь этот тред?',
+ 'conversations.threadGoal.save': 'Сохранить',
+ 'conversations.threadGoal.cancel': 'Отмена',
+ 'conversations.threadGoal.edit': 'Изменить цель',
+ 'conversations.threadGoal.clear': 'Очистить цель',
+ 'conversations.threadGoal.complete': 'Отметить выполненным',
+ 'conversations.threadGoal.pause': 'Пауза',
+ 'conversations.threadGoal.resume': 'Возобновить',
+ 'conversations.threadGoal.tokensSuffix': 'токены',
+ 'conversations.threadGoal.status.active': 'Активна',
+ 'conversations.threadGoal.status.paused': 'На паузе',
+ 'conversations.threadGoal.status.budget_limited': 'Бюджет исчерпан',
+ 'conversations.threadGoal.status.complete': 'Завершена',
'conversations.taskKanban.approval.default': 'По умолчанию',
'conversations.taskKanban.approval.notRequired': 'Не требуется',
'conversations.taskKanban.approval.notRequiredBadge': 'нет одобрения',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 44fb49d30..91140ba13 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -2902,6 +2902,21 @@ const messages: TranslationMap = {
'conversations.taskKanban.moveRight': '向右移动',
'conversations.taskKanban.title': '任务',
'conversations.threadTodo.title': '计划',
+ 'conversations.threadGoal.label': '目标',
+ 'conversations.threadGoal.setCta': '设定目标',
+ 'conversations.threadGoal.placeholder': '这个对话需要完成什么?',
+ 'conversations.threadGoal.save': '保存',
+ 'conversations.threadGoal.cancel': '取消',
+ 'conversations.threadGoal.edit': '编辑目标',
+ 'conversations.threadGoal.clear': '清除目标',
+ 'conversations.threadGoal.complete': '标记为完成',
+ 'conversations.threadGoal.pause': '暂停',
+ 'conversations.threadGoal.resume': '继续',
+ 'conversations.threadGoal.tokensSuffix': '令牌',
+ 'conversations.threadGoal.status.active': '进行中',
+ 'conversations.threadGoal.status.paused': '已暂停',
+ 'conversations.threadGoal.status.budget_limited': '预算已用尽',
+ 'conversations.threadGoal.status.complete': '已完成',
'conversations.taskKanban.approval.default': '默认',
'conversations.taskKanban.approval.notRequired': '无需审批',
'conversations.taskKanban.approval.notRequiredBadge': '无需审批',
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 805b5528f..1e15af3d1 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -100,6 +100,11 @@ import {
} from './conversations/components/BackgroundProcessesPanel';
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
+import {
+ ThreadGoalEditorPanel,
+ ThreadGoalFooterTrigger,
+ useThreadGoal,
+} from './conversations/components/ThreadGoalChip';
import { ThreadTodoStrip } from './conversations/components/ThreadTodoStrip';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
@@ -239,6 +244,10 @@ const Conversations = ({
: false;
const firstActiveThreadId = Object.keys(activeThreadIds)[0] ?? null;
+ // Thread-goal controller shared by the footer trigger (under the composer)
+ // and the editor panel (above the composer).
+ const threadGoal = useThreadGoal(selectedThreadId ?? null);
+
const [inputValue, setInputValue] = useState('');
const [attachments, setAttachments] = useState([]);
const fileInputRef = useRef(null);
@@ -894,12 +903,6 @@ const Conversations = ({
// may proceed concurrently.
if (selectedThreadId && pendingSendsRef.current.has(selectedThreadId)) return;
- // If the user just flipped the Super Context toggle, make sure that config
- // write has landed before the core builds this thread's session (which
- // reads `context.super_context_enabled`). Resolves instantly when nothing
- // is pending.
- await whenSuperContextWriteSettled();
-
const normalized = text ?? inputValue;
const trimmedInput = normalized.trim();
@@ -944,6 +947,12 @@ const Conversations = ({
if (!sendingThreadId) return;
pendingSendsRef.current.add(sendingThreadId);
addPendingSendingThread(sendingThreadId);
+ // If the user just flipped the Super Context toggle, make sure that config
+ // write has landed before the core builds this thread's session (which
+ // reads `context.super_context_enabled`). Done AFTER the duplicate-send
+ // guard above is set so this await can't open a check→add race for rapid
+ // repeat clicks. Resolves instantly when nothing is pending.
+ await whenSuperContextWriteSettled();
const pendingAttachments = attachments.slice();
const modelOverride =
agentProfiles.find(p => p.id === selectedAgentProfileId)?.modelOverride ?? CHAT_MODEL_HINT;
@@ -2659,12 +2668,6 @@ const Conversations = ({
) : inputMode === 'text' ? (
<>
- {selectedThreadId && (queuedFollowupsByThread[selectedThreadId]?.length ?? 0) > 0 && (
-
void handleClearQueuedFollowups()}
- />
- )}
0 ? (
+ void handleClearQueuedFollowups()}
+ />
+ ) : null,
+ ,
+ ]}
/>
>
) : (
@@ -2762,7 +2778,11 @@ const Conversations = ({
-
+
+
+ {/* Set/show the thread goal; click opens the editor above the composer. */}
+
+
{!isSidebar && (
({ useT: () => ({ t: (key: string) => key }) }));
+
+function goal(partial: Partial
= {}): ThreadGoal {
+ return {
+ threadId: 't1',
+ goalId: 'g1',
+ objective: 'Ship the release',
+ status: 'active',
+ tokenBudget: 1000,
+ tokensUsed: 250,
+ timeUsedSeconds: 0,
+ createdAtMs: 0,
+ updatedAtMs: 0,
+ continuationSuppressed: false,
+ ...partial,
+ };
+}
+
+function stubApi(initial: ThreadGoal | null) {
+ return {
+ get: vi.fn().mockResolvedValue(initial),
+ set: vi.fn().mockResolvedValue(goal()),
+ complete: vi.fn().mockResolvedValue(goal({ status: 'complete' })),
+ pause: vi.fn().mockResolvedValue(goal({ status: 'paused' })),
+ resume: vi.fn().mockResolvedValue(goal()),
+ clear: vi.fn().mockResolvedValue(true),
+ } as unknown as typeof import('../../../services/api/threadGoalApi').threadGoalApi;
+}
+
+/** Harness wiring the shared controller to both pieces, as Conversations does. */
+function Harness({ api }: { api: ReturnType }) {
+ const ctl = useThreadGoal('t1', api);
+ return (
+
+
+
+
+ );
+}
+
+describe('ThreadGoal footer trigger + editor panel', () => {
+ it('shows a footer "Set goal" trigger and no editor when empty', async () => {
+ const api = stubApi(null);
+ render( );
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('t1'));
+ expect(await screen.findByText('conversations.threadGoal.setCta')).toBeTruthy();
+ // Editor stays closed until clicked.
+ expect(screen.queryByPlaceholderText('conversations.threadGoal.placeholder')).toBeNull();
+ });
+
+ it('clicking the trigger opens the editor input above; saving calls set', async () => {
+ const api = stubApi(null);
+ render( );
+ fireEvent.click(await screen.findByText('conversations.threadGoal.setCta'));
+ const input = screen.getByPlaceholderText('conversations.threadGoal.placeholder');
+ fireEvent.change(input, { target: { value: 'New objective' } });
+ fireEvent.click(screen.getByText('conversations.threadGoal.save'));
+ await waitFor(() => expect(api.set).toHaveBeenCalledWith('t1', 'New objective'));
+ });
+
+ it('shows the goal status + objective in the footer trigger when set', async () => {
+ const api = stubApi(goal());
+ render( );
+ expect(await screen.findByText('Ship the release')).toBeTruthy();
+ expect(screen.getByText('conversations.threadGoal.status.active')).toBeTruthy();
+ // Editor (with the token budget) is closed until the trigger is clicked.
+ expect(screen.queryByText(/250 \/ 1,000/)).toBeNull();
+ });
+
+ it('opening an existing goal seeds the input and exposes lifecycle actions', async () => {
+ const api = stubApi(goal());
+ render( );
+ fireEvent.click(await screen.findByTitle('Ship the release'));
+ const input = screen.getByPlaceholderText(
+ 'conversations.threadGoal.placeholder'
+ );
+ expect(input.value).toBe('Ship the release');
+ expect(screen.getByText(/250 \/ 1,000/)).toBeTruthy();
+ fireEvent.click(screen.getByLabelText('conversations.threadGoal.complete'));
+ await waitFor(() => expect(api.complete).toHaveBeenCalledWith('t1'));
+ });
+
+ it('shows Resume (not Pause) in the editor for a paused goal', async () => {
+ const api = stubApi(goal({ status: 'paused' }));
+ render( );
+ fireEvent.click(await screen.findByTitle('Ship the release'));
+ expect(screen.getByLabelText('conversations.threadGoal.resume')).toBeTruthy();
+ expect(screen.queryByLabelText('conversations.threadGoal.pause')).toBeNull();
+ });
+});
diff --git a/app/src/pages/conversations/components/ThreadGoalChip.tsx b/app/src/pages/conversations/components/ThreadGoalChip.tsx
new file mode 100644
index 000000000..3e6bc4ba1
--- /dev/null
+++ b/app/src/pages/conversations/components/ThreadGoalChip.tsx
@@ -0,0 +1,369 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useT } from '../../../lib/i18n/I18nContext';
+import {
+ type ThreadGoal,
+ threadGoalApi,
+ type ThreadGoalStatus,
+} from '../../../services/api/threadGoalApi';
+
+/**
+ * Per-thread goal UI — a Codex-style completion contract the agent pursues
+ * across turns. Split into two pieces sharing one {@link useThreadGoal}
+ * controller so the trigger can live in the composer footer while the editor
+ * opens above the composer:
+ *
+ * - {@link ThreadGoalFooterTrigger} — a compact affordance in the footer under
+ * the composer ("Set goal" when empty; status + objective when set). Click to
+ * open the editor.
+ * - {@link ThreadGoalEditorPanel} — the input field + status/budget + actions,
+ * rendered above the composer, shown only while expanded.
+ *
+ * Distinct from the global long-term goals list (Intelligence tab) and the
+ * thread task board (todo strip). Liveness: fetch on thread change + light poll
+ * so agent/continuation-driven changes surface without a manual refresh.
+ */
+
+const POLL_INTERVAL_MS = 10_000;
+
+/** Shared controller returned by {@link useThreadGoal}. */
+export interface ThreadGoalController {
+ threadId: string | null;
+ goal: ThreadGoal | null;
+ /** Whether the editor panel (above the composer) is open. */
+ expanded: boolean;
+ draft: string;
+ busy: boolean;
+ setDraft: (value: string) => void;
+ /** Open the editor, seeding the draft from the current objective. */
+ open: () => void;
+ close: () => void;
+ /** Toggle the editor open/closed (open seeds the draft). */
+ toggle: () => void;
+ /** Persist the draft as the objective (no-op on empty), then collapse. */
+ save: () => void;
+ complete: () => void;
+ pause: () => void;
+ resume: () => void;
+ clear: () => void;
+}
+
+/** Tailwind classes per status, using the app's ocean/sage/amber/coral palette. */
+function statusClasses(status: ThreadGoalStatus): string {
+ switch (status) {
+ case 'active':
+ return 'bg-primary-50 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200';
+ case 'paused':
+ return 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300';
+ case 'budget_limited':
+ return 'bg-amber-50 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200';
+ case 'complete':
+ return 'bg-sage-50 text-sage-700 dark:bg-sage-900/40 dark:text-sage-200';
+ default:
+ return 'bg-stone-100 text-stone-600';
+ }
+}
+
+/**
+ * Goal state + actions for `threadId`. Call once in the parent and hand the
+ * result to both the footer trigger and the editor panel so they stay in sync.
+ */
+export function useThreadGoal(
+ threadId: string | null,
+ api: typeof threadGoalApi = threadGoalApi
+): ThreadGoalController {
+ const [goal, setGoal] = useState(null);
+ const [expanded, setExpanded] = useState(false);
+ const [draft, setDraft] = useState('');
+ const [busy, setBusy] = useState(false);
+ // Guard against thread-switch races resolving onto the wrong thread.
+ const activeThread = useRef(threadId);
+
+ const refresh = useCallback(async () => {
+ if (!threadId) {
+ setGoal(null);
+ return;
+ }
+ try {
+ const g = await api.get(threadId);
+ if (activeThread.current === threadId) setGoal(g);
+ } catch {
+ /* best-effort; keep last known goal */
+ }
+ }, [api, threadId]);
+
+ // Reset the editor + cached goal when the thread changes. Done during render
+ // (React's sanctioned "reset state on prop change" pattern) rather than in an
+ // effect, so it's synchronous and lint-clean.
+ if (activeThread.current !== threadId) {
+ activeThread.current = threadId;
+ setExpanded(false);
+ setGoal(null);
+ }
+
+ // Fetch on mount/thread-change and poll lightly. `refresh` is async, so its
+ // setState lands in a later microtask (not a synchronous effect write).
+ useEffect(() => {
+ if (!threadId) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState lands post-await
+ void refresh();
+ const id = window.setInterval(() => void refresh(), POLL_INTERVAL_MS);
+ return () => window.clearInterval(id);
+ }, [threadId, refresh]);
+
+ const runAction = useCallback(
+ async (fn: () => Promise, collapse: boolean) => {
+ setBusy(true);
+ try {
+ await fn();
+ await refresh();
+ if (collapse) setExpanded(false);
+ } finally {
+ setBusy(false);
+ }
+ },
+ [refresh]
+ );
+
+ const open = useCallback(() => {
+ setDraft(goal?.objective ?? '');
+ setExpanded(true);
+ }, [goal]);
+
+ const close = useCallback(() => setExpanded(false), []);
+
+ const toggle = useCallback(() => {
+ setExpanded(prev => {
+ if (!prev) setDraft(goal?.objective ?? '');
+ return !prev;
+ });
+ }, [goal]);
+
+ const save = useCallback(() => {
+ if (!threadId) return;
+ const objective = draft.trim();
+ if (!objective) return;
+ void runAction(() => api.set(threadId, objective), true);
+ }, [api, draft, runAction, threadId]);
+
+ const complete = useCallback(() => {
+ if (threadId) void runAction(() => api.complete(threadId), true);
+ }, [api, runAction, threadId]);
+ const pause = useCallback(() => {
+ if (threadId) void runAction(() => api.pause(threadId), false);
+ }, [api, runAction, threadId]);
+ const resume = useCallback(() => {
+ if (threadId) void runAction(() => api.resume(threadId), false);
+ }, [api, runAction, threadId]);
+ const clear = useCallback(() => {
+ if (threadId) void runAction(() => api.clear(threadId), true);
+ }, [api, runAction, threadId]);
+
+ return {
+ threadId,
+ goal,
+ expanded,
+ draft,
+ busy,
+ setDraft,
+ open,
+ close,
+ toggle,
+ save,
+ complete,
+ pause,
+ resume,
+ clear,
+ };
+}
+
+/** Compact trigger for the composer footer: "Set goal" or the current goal. */
+export function ThreadGoalFooterTrigger({
+ ctl,
+}: {
+ ctl: ThreadGoalController;
+}): React.ReactElement | null {
+ const { t } = useT();
+ if (!ctl.threadId) return null;
+
+ if (!ctl.goal) {
+ return (
+
+ ◎
+ {t('conversations.threadGoal.setCta')}
+
+ );
+ }
+
+ return (
+
+
+ ◎
+
+
+ {t(`conversations.threadGoal.status.${ctl.goal.status}`)}
+
+
+
+ );
+}
+
+/** Expanded editor above the composer: input + budget + lifecycle actions. */
+export function ThreadGoalEditorPanel({
+ ctl,
+}: {
+ ctl: ThreadGoalController;
+}): React.ReactElement | null {
+ const { t } = useT();
+ if (!ctl.threadId || !ctl.expanded) return null;
+
+ const goal = ctl.goal;
+ const budgetText =
+ goal && typeof goal.tokenBudget === 'number' && goal.tokenBudget > 0
+ ? `${goal.tokensUsed.toLocaleString()} / ${goal.tokenBudget.toLocaleString()} ${t('conversations.threadGoal.tokensSuffix')}`
+ : null;
+
+ return (
+
+ {/* Controls row — lifecycle (left) + budget and Cancel/Save (right) —
+ sits above the input so the input gets the full width below. */}
+
+ {goal && goal.status === 'active' && (
+
+ )}
+ {goal && goal.status === 'paused' && (
+
+ )}
+ {goal && goal.status !== 'complete' && (
+
+ )}
+ {goal && (
+
+ )}
+
+ {budgetText && (
+
+ {budgetText}
+
+ )}
+
+ {t('conversations.threadGoal.cancel')}
+
+
+ {t('conversations.threadGoal.save')}
+
+
+
+
+ {/* Full-width objective input below the controls. */}
+
ctl.setDraft(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') ctl.save();
+ if (e.key === 'Escape') ctl.close();
+ }}
+ placeholder={t('conversations.threadGoal.placeholder')}
+ aria-label={t('conversations.threadGoal.placeholder')}
+ className="w-full border-0 bg-transparent text-sm text-stone-800 outline-none focus:outline-none focus:ring-0 placeholder:text-stone-400 dark:text-neutral-100"
+ />
+
+ );
+}
+
+/**
+ * Single-line label that gently marquees (ping-pong scroll) only when the text
+ * overflows its max width; otherwise it truncates. The scroll distance is
+ * measured and fed to the `goal-marquee` keyframe via a CSS variable.
+ */
+function MarqueeText({
+ text,
+ className = '',
+}: {
+ text: string;
+ className?: string;
+}): React.ReactElement {
+ const outerRef = useRef(null);
+ const innerRef = useRef(null);
+ const [shift, setShift] = useState(0);
+
+ useEffect(() => {
+ const outer = outerRef.current;
+ const inner = innerRef.current;
+ if (!outer || !inner) return;
+ const overflow = inner.scrollWidth - outer.clientWidth;
+ setShift(overflow > 4 ? overflow + 8 : 0);
+ }, [text]);
+
+ return (
+
+ 0 ? 'animate-goal-marquee' : 'truncate'}`}
+ style={
+ shift > 0 ? ({ '--goal-marquee-shift': `-${shift}px` } as React.CSSProperties) : undefined
+ }>
+ {text}
+
+
+ );
+}
+
+function PanelButton({
+ label,
+ onClick,
+ disabled,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+}): React.ReactElement {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/app/src/services/api/__tests__/threadGoalApi.test.ts b/app/src/services/api/__tests__/threadGoalApi.test.ts
new file mode 100644
index 000000000..454030097
--- /dev/null
+++ b/app/src/services/api/__tests__/threadGoalApi.test.ts
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockCallCoreRpc = vi.fn();
+
+vi.mock('../../coreRpcClient', () => ({
+ callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
+}));
+
+const { threadGoalApi } = await import('../threadGoalApi');
+
+const goal = {
+ threadId: 't1',
+ goalId: 'g-uuid',
+ objective: 'ship it',
+ status: 'active' as const,
+ tokenBudget: 1000,
+ tokensUsed: 100,
+ timeUsedSeconds: 5,
+ createdAtMs: 1,
+ updatedAtMs: 2,
+ continuationSuppressed: false,
+};
+
+describe('threadGoalApi', () => {
+ beforeEach(() => mockCallCoreRpc.mockReset());
+
+ it('get returns the bare goal envelope', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ goal });
+ const out = await threadGoalApi.get('t1');
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.thread_goals_get',
+ params: { thread_id: 't1' },
+ });
+ expect(out?.objective).toBe('ship it');
+ });
+
+ it('get returns null when the thread has no goal', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ goal: null });
+ expect(await threadGoalApi.get('t1')).toBeNull();
+ });
+
+ it('set unwraps the { result, logs } envelope and forwards the budget', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: { goal }, logs: ['set'] });
+ const out = await threadGoalApi.set('t1', 'ship it', 1000);
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.thread_goals_set',
+ params: { thread_id: 't1', objective: 'ship it', token_budget: 1000 },
+ });
+ expect(out?.goalId).toBe('g-uuid');
+ });
+
+ it('set omits token_budget when not provided', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: { goal }, logs: [] });
+ await threadGoalApi.set('t1', 'ship it');
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.thread_goals_set',
+ params: { thread_id: 't1', objective: 'ship it' },
+ });
+ });
+
+ it('complete / pause / resume hit the right methods', async () => {
+ mockCallCoreRpc.mockResolvedValue({ result: { goal }, logs: [] });
+ await threadGoalApi.complete('t1');
+ await threadGoalApi.pause('t1');
+ await threadGoalApi.resume('t1');
+ const methods = mockCallCoreRpc.mock.calls.map(c => (c[0] as { method: string }).method);
+ expect(methods).toEqual([
+ 'openhuman.thread_goals_complete',
+ 'openhuman.thread_goals_pause',
+ 'openhuman.thread_goals_resume',
+ ]);
+ });
+
+ it('clear returns the removed flag', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: { removed: true }, logs: ['cleared'] });
+ expect(await threadGoalApi.clear('t1')).toBe(true);
+ mockCallCoreRpc.mockResolvedValueOnce({ removed: false });
+ expect(await threadGoalApi.clear('t1')).toBe(false);
+ });
+});
diff --git a/app/src/services/api/threadGoalApi.ts b/app/src/services/api/threadGoalApi.ts
new file mode 100644
index 000000000..153610dd0
--- /dev/null
+++ b/app/src/services/api/threadGoalApi.ts
@@ -0,0 +1,114 @@
+/**
+ * Frontend client for the thread-level goal surface (`openhuman.thread_goals_*`).
+ *
+ * A thread goal is a single, thread-scoped "completion contract" (Codex-style)
+ * the agent pursues across turns — distinct from the global long-term goals list
+ * ({@link ./goalsApi}) and the per-thread task board. The Rust handlers persist
+ * one goal per thread under `/thread_goals/.json`.
+ *
+ * Wire shapes: `get` returns the bare `{ goal }` envelope; the mutation methods
+ * wrap their value in `{ result, logs }` when logs are present. {@link unwrap}
+ * normalises both so callers always get the typed payload.
+ */
+import debug from 'debug';
+
+import { callCoreRpc } from '../coreRpcClient';
+
+const log = debug('openhuman:threadGoalApi');
+
+/** Lifecycle state of a thread goal (mirrors the Rust `ThreadGoalStatus`). */
+export type ThreadGoalStatus = 'active' | 'paused' | 'budget_limited' | 'complete';
+
+/** A single thread-scoped goal (mirrors the Rust `ThreadGoal`, camelCase wire). */
+export interface ThreadGoal {
+ threadId: string;
+ goalId: string;
+ objective: string;
+ status: ThreadGoalStatus;
+ tokenBudget?: number | null;
+ tokensUsed: number;
+ timeUsedSeconds: number;
+ createdAtMs: number;
+ updatedAtMs: number;
+ continuationSuppressed: boolean;
+}
+
+/** Unwrap the optional `{ result, logs }` RpcOutcome envelope. */
+function unwrap(res: unknown): T {
+ if (res && typeof res === 'object' && 'result' in (res as Record)) {
+ return (res as { result: T }).result;
+ }
+ return res as T;
+}
+
+/** Pull a `ThreadGoal | null` out of a `{ goal }` envelope. */
+function extractGoal(res: unknown): ThreadGoal | null {
+ const value = unwrap<{ goal?: ThreadGoal | null }>(res);
+ return value && typeof value === 'object' && value.goal ? value.goal : null;
+}
+
+export const threadGoalApi = {
+ /** Get the thread's goal, or `null` when it has none. */
+ get: async (threadId: string): Promise => {
+ log('get thread=%s', threadId);
+ const res = await callCoreRpc({
+ method: 'openhuman.thread_goals_get',
+ params: { thread_id: threadId },
+ });
+ return extractGoal(res);
+ },
+
+ /** Create or replace the thread's goal. */
+ set: async (
+ threadId: string,
+ objective: string,
+ tokenBudget?: number
+ ): Promise => {
+ log('set thread=%s budget=%o', threadId, tokenBudget);
+ const params: Record = { thread_id: threadId, objective };
+ if (typeof tokenBudget === 'number') params.token_budget = tokenBudget;
+ const res = await callCoreRpc({ method: 'openhuman.thread_goals_set', params });
+ return extractGoal(res);
+ },
+
+ /** Mark the thread's goal complete. */
+ complete: async (threadId: string): Promise => {
+ log('complete thread=%s', threadId);
+ const res = await callCoreRpc({
+ method: 'openhuman.thread_goals_complete',
+ params: { thread_id: threadId },
+ });
+ return extractGoal(res);
+ },
+
+ /** Pause an active goal. */
+ pause: async (threadId: string): Promise => {
+ log('pause thread=%s', threadId);
+ const res = await callCoreRpc({
+ method: 'openhuman.thread_goals_pause',
+ params: { thread_id: threadId },
+ });
+ return extractGoal(res);
+ },
+
+ /** Resume a paused goal. */
+ resume: async (threadId: string): Promise => {
+ log('resume thread=%s', threadId);
+ const res = await callCoreRpc({
+ method: 'openhuman.thread_goals_resume',
+ params: { thread_id: threadId },
+ });
+ return extractGoal(res);
+ },
+
+ /** Clear (delete) the thread's goal. Returns whether one existed. */
+ clear: async (threadId: string): Promise => {
+ log('clear thread=%s', threadId);
+ const res = await callCoreRpc({
+ method: 'openhuman.thread_goals_clear',
+ params: { thread_id: threadId },
+ });
+ const value = unwrap<{ removed?: boolean }>(res);
+ return !!(value && typeof value === 'object' && value.removed);
+ },
+};
diff --git a/app/tailwind.config.js b/app/tailwind.config.js
index 6fa648c5b..3d6489b7a 100644
--- a/app/tailwind.config.js
+++ b/app/tailwind.config.js
@@ -239,6 +239,10 @@ module.exports = {
'float': 'float 3s ease-in-out infinite',
'ticker': 'ticker 30s linear infinite',
'wiggle': 'wiggle 0.5s ease-in-out',
+ // Gentle ping-pong scroll for overflowing single-line labels (e.g. a
+ // long thread-goal objective). Distance is supplied per-element via the
+ // `--goal-marquee-shift` CSS var; `alternate` returns it to the start.
+ 'goal-marquee': 'goalMarquee 6s ease-in-out infinite alternate',
},
keyframes: {
@@ -283,6 +287,10 @@ module.exports = {
'25%': { transform: 'rotate(-9deg)' },
'75%': { transform: 'rotate(9deg)' },
},
+ goalMarquee: {
+ '0%, 18%': { transform: 'translateX(0)' },
+ '82%, 100%': { transform: 'translateX(var(--goal-marquee-shift, 0px))' },
+ },
},
// Backdrop blur for glass morphism
diff --git a/src/core/all.rs b/src/core/all.rs
index 0d73440b4..c01555470 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -219,6 +219,8 @@ fn build_registered_controllers() -> Vec {
controllers.extend(crate::openhuman::memory::all_memory_registered_controllers());
// Long-term goals list (editable list + turn-based enrichment agent)
controllers.extend(crate::openhuman::memory_goals::all_memory_goals_registered_controllers());
+ // Thread-level goal (Codex-style per-thread completion contract)
+ controllers.extend(crate::openhuman::thread_goals::all_thread_goals_registered_controllers());
// Memory tree ingestion layer (#707 — canonicalised chunks with provenance)
controllers.extend(crate::openhuman::memory_tree::all_memory_tree_registered_controllers());
// Memory tree retrieval layer (#710 — LLM-callable read tools over the tree)
@@ -403,6 +405,7 @@ fn build_declared_controller_schemas() -> Vec {
schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas());
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
schemas.extend(crate::openhuman::memory_goals::all_memory_goals_controller_schemas());
+ schemas.extend(crate::openhuman::thread_goals::all_thread_goals_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_memory_tree_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_retrieval_controller_schemas());
schemas.extend(
@@ -535,6 +538,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"memory_goals" => Some(
"The agent's long-term goals list for working with the user — editable items plus turn-based enrichment.",
),
+ "thread_goals" => Some(
+ "The thread-level goal — a Codex-style per-thread completion contract with lifecycle, token budget, and idle continuation.",
+ ),
"memory_tree" => Some(
"Canonical chunk ingestion, provenance capture, and chunk retrieval for source-grounded memory.",
),
diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs
index e9551aff3..5d2f5219e 100644
--- a/src/core/event_bus/events.rs
+++ b/src/core/event_bus/events.rs
@@ -1012,6 +1012,17 @@ pub enum DomainEvent {
reason: String,
},
+ // ── Thread goals ──────────────────────────────────────────────────
+ /// A thread's goal was created, replaced, or transitioned state
+ /// (active/paused/budget_limited/complete). Drives the desktop goal chip.
+ ThreadGoalUpdated {
+ thread_id: String,
+ goal_id: String,
+ status: String,
+ },
+ /// A thread's goal was cleared (deleted).
+ ThreadGoalCleared { thread_id: String },
+
// ── Backend Meet Bot ──────────────────────────────────────────────
/// Backend gmeet bot successfully joined the meeting.
BackendMeetJoined {
@@ -1242,6 +1253,8 @@ impl DomainEvent {
Self::TaskPlanAwaitingApproval { .. } | Self::TaskRunReclaimed { .. } => "agent",
+ Self::ThreadGoalUpdated { .. } | Self::ThreadGoalCleared { .. } => "agent",
+
Self::SubconsciousTriggerProcessed { .. } => "subconscious",
Self::Voice(_) => "voice",
@@ -1392,6 +1405,8 @@ impl DomainEvent {
Self::TaskSourceFetchFailed { .. } => "TaskSourceFetchFailed",
Self::TaskPlanAwaitingApproval { .. } => "TaskPlanAwaitingApproval",
Self::TaskRunReclaimed { .. } => "TaskRunReclaimed",
+ Self::ThreadGoalUpdated { .. } => "ThreadGoalUpdated",
+ Self::ThreadGoalCleared { .. } => "ThreadGoalCleared",
Self::BackendMeetJoined { .. } => "BackendMeetJoined",
Self::BackendMeetLeft { .. } => "BackendMeetLeft",
Self::BackendMeetReply { .. } => "BackendMeetReply",
diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs
index a25e433bc..5d48602ab 100644
--- a/src/openhuman/about_app/catalog_data.rs
+++ b/src/openhuman/about_app/catalog_data.rs
@@ -378,6 +378,27 @@ pub(super) const CAPABILITIES: &[Capability] = &[
// the device during a reflect pass (CRUD/storage stays local).
privacy: DERIVED_TO_BACKEND,
},
+ Capability {
+ id: "conversation.thread_goal",
+ name: "Thread Goal",
+ domain: "conversation",
+ category: CapabilityCategory::Conversation,
+ description: "A single, thread-scoped goal (Codex-style \"completion contract\") the \
+ assistant keeps pursuing across turns, interrupts, resumes, and budget boundaries — \
+ distinct from the long-term goals list and the per-thread task board. Stored locally \
+ (one goal per thread), with a lifecycle (active/paused/budget_limited/complete) and an \
+ optional token budget. The active goal is injected into context each turn; the context \
+ scout proposes a goal on a fresh thread (only if none is set) and the orchestrator can \
+ set/refine it. When enabled, idle threads can autonomously continue toward the goal.",
+ how_to: "Set/edit via the goal chip above the composer in Conversations, or the \
+ thread_goals.* RPC (get/set/complete/pause/resume/clear); the assistant manages it via \
+ the goal_set / goal_get / goal_complete tools. Autonomous continuation is opt-in via \
+ heartbeat.goal_continuation_enabled.",
+ status: CapabilityStatus::Beta,
+ // Goal CRUD/storage is local; autonomous continuation (opt-in) runs a
+ // cloud agentic model, so objective/context can leave the device then.
+ privacy: DERIVED_TO_BACKEND,
+ },
Capability {
id: "intelligence.memory_tree_retrieval",
name: "Memory Tree Retrieval (chat)",
diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs
index 216bc4743..c52104804 100644
--- a/src/openhuman/agent/harness/session/builder/factory.rs
+++ b/src/openhuman/agent/harness/session/builder/factory.rs
@@ -270,6 +270,12 @@ impl Agent {
"model_council_readonly",
);
agent.set_agent_definition_name(format!("model_council_{safe_name}"));
+ // Council jurors are non-interactive, single-shot read-only model calls
+ // built from the orchestrator definition. The first-turn super-context
+ // pass (default-on) is an interactive convenience for the user-facing
+ // chat orchestrator — running it per juror would add an unexpected
+ // `context_scout` LLM call to each jury seat. Suppress it here.
+ agent.context.set_super_context_enabled(false);
if let Some(model) = model_override
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs
index c592afea3..8b4dd55cf 100644
--- a/src/openhuman/agent/harness/session/turn/core.rs
+++ b/src/openhuman/agent/harness/session/turn/core.rs
@@ -368,6 +368,53 @@ impl Agent {
}
}
+ // ── Thread goal (Codex-style per-thread completion contract) ─────────
+ // Load this thread's durable goal once per turn and prepend a compact
+ // [active_goal] block so the objective + live status/budget steer the
+ // turn. Rides the per-turn context (NOT the cached system-prompt prefix)
+ // so edits take effect immediately. `active_goal` is reused below to arm
+ // the budget stop hook around the engine call.
+ // Capture the workspace path for the budget stop hook built after the
+ // `turn_body` coroutine (which borrows `&mut self`) is constructed.
+ let goal_workspace_dir = self.workspace_dir.clone();
+ let active_goal = {
+ let loaded = crate::openhuman::thread_goals::runtime::load_for_current_thread(
+ &self.workspace_dir,
+ )
+ .await;
+ // Thread-resume semantics: the user re-engaging a thread reactivates a
+ // paused goal (Codex's ThreadResumed). Best-effort; on failure keep
+ // the loaded (paused) goal so we still surface it.
+ match loaded {
+ Some(goal)
+ if matches!(
+ goal.status,
+ crate::openhuman::thread_goals::ThreadGoalStatus::Paused
+ ) =>
+ {
+ crate::openhuman::thread_goals::runtime::resume_for_current_thread(
+ &self.workspace_dir,
+ )
+ .await
+ .unwrap_or(Some(goal))
+ }
+ other => other,
+ }
+ };
+ if let Some(ref goal) = active_goal {
+ if let Some(block) =
+ crate::openhuman::thread_goals::runtime::active_goal_context_block(goal)
+ {
+ log::info!(
+ "[thread_goals] injecting active_goal block status={} budget={:?} ({} chars)",
+ goal.status.as_str(),
+ goal.token_budget,
+ block.chars().count()
+ );
+ context.push_str(&block);
+ }
+ }
+
let enriched = if context.is_empty() {
log::info!("[agent] no memory context found — using raw user message");
self.last_memory_context = None;
@@ -739,6 +786,17 @@ impl Agent {
self.context.record_tool_calls(records.len());
+ // Account this turn's tokens (prompt + completion) and elapsed time
+ // against the thread's active goal, flipping it to budget_limited
+ // when the cap is crossed. Best-effort — never fails the turn.
+ crate::openhuman::thread_goals::runtime::account_turn_against_goal(
+ &self.workspace_dir,
+ cumulative_input,
+ cumulative_output,
+ turn_started.elapsed().as_secs(),
+ )
+ .await;
+
// For a clean final response the observer already pushed the
// assistant message + persisted. For a max-iteration checkpoint or
// circuit-breaker halt the engine returned the text without pushing
@@ -799,7 +857,31 @@ impl Agent {
// that any `spawn_subagent` tool call fired during the loop can
// read the parent's provider, tools, model, and workspace via
// the PARENT_CONTEXT task-local.
- let result = harness::with_parent_context(parent_context, turn_body).await;
+ // Arm the thread-goal budget stop hook for this turn when an active,
+ // budgeted goal exists — it hard-stops the loop the moment running usage
+ // would exceed the cap (so an autonomous run can't blow past it between
+ // accounting points). Merge with any ambient stop hooks rather than
+ // clobbering them. No budgeted active goal → no extra hook, no wrap.
+ let mut turn_stop_hooks = crate::openhuman::agent::stop_hooks::current_stop_hooks();
+ if let Some(ref goal) = active_goal {
+ if let Some(hook) =
+ crate::openhuman::thread_goals::runtime::GoalBudgetStopHook::for_goal(
+ &goal_workspace_dir,
+ goal,
+ )
+ {
+ turn_stop_hooks.push(std::sync::Arc::new(hook));
+ }
+ }
+ let result = if turn_stop_hooks.is_empty() {
+ harness::with_parent_context(parent_context, turn_body).await
+ } else {
+ harness::with_parent_context(
+ parent_context,
+ crate::openhuman::agent::stop_hooks::with_stop_hooks(turn_stop_hooks, turn_body),
+ )
+ .await
+ };
// Session transcript persistence lives INSIDE the turn body —
// one write per provider response, fired right after the
diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs
index 68ef5b2e4..2d53dcd8d 100644
--- a/src/openhuman/agent/turn_origin.rs
+++ b/src/openhuman/agent/turn_origin.rs
@@ -75,6 +75,9 @@ pub enum TrustedAutomationSource {
/// from an external sync source (Gmail / Slack / Notion / etc.).
/// Treated as untrusted: external-effect tool surface blocked.
SubconsciousTainted,
+ /// Autonomous continuation of a thread goal: the heartbeat injected a turn
+ /// to keep working an idle `active` goal the user explicitly created.
+ GoalContinuation,
}
tokio::task_local! {
diff --git a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
index c88b9e0ba..64e5cd502 100644
--- a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
+++ b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs
@@ -18,6 +18,7 @@ use crate::openhuman::agent::harness::subagent_runner::{
run_subagent, SubagentRunOptions, SubagentRunStatus,
};
use crate::openhuman::agent::progress::AgentProgress;
+use crate::openhuman::inference::provider::thread_context::current_thread_id;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
@@ -222,6 +223,57 @@ pub async fn run_context_scout(question: &str, focus: Option<&str>) -> anyhow::R
})
.await;
}
+
+ // Bootstrap this thread's goal from the scout's proposal — but
+ // ONLY when the thread has none yet. The orchestrator stays
+ // authoritative (it sets/replaces via `goal_set`); the
+ // context-gathering path just seeds a goal on the first scout of
+ // a fresh chat so the harness has something to steer toward.
+ // Runs for both entry points (LLM-invoked tool + harness
+ // super-context first turn). Best-effort — never fails the call.
+ if let (Some(parent), Some(thread_id)) = (current_parent(), current_thread_id()) {
+ if let Some(objective) =
+ AgentPrepareContextTool::parse_proposed_goal(&outcome.output)
+ {
+ match crate::openhuman::thread_goals::store::set_if_absent(
+ &parent.workspace_dir,
+ &thread_id,
+ &objective,
+ None,
+ )
+ .await
+ {
+ Ok(Some(goal)) => {
+ tracing::info!(
+ target: "agent_prepare_context",
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ "[agent_prepare_context] bootstrapped thread goal from scout proposal"
+ );
+ publish_global(DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ });
+ }
+ Ok(None) => {
+ tracing::debug!(
+ target: "agent_prepare_context",
+ thread_id = %thread_id,
+ "[agent_prepare_context] thread already has a goal — scout proposal not applied"
+ );
+ }
+ Err(e) => {
+ tracing::debug!(
+ target: "agent_prepare_context",
+ error = %e,
+ "[agent_prepare_context] failed to persist scout-proposed goal"
+ );
+ }
+ }
+ }
+ }
+
Ok(ToolResult::success(outcome.output))
}
// The scout has no `ask_user_clarification` tool, so this
@@ -388,6 +440,25 @@ impl AgentPrepareContextTool {
);
prompt
}
+
+ /// Extract the scout's `proposed_goal:` line from a `[context_bundle]`, if
+ /// present and meaningful. Returns `None` for a missing line or an explicit
+ /// `none`. The prefix is matched case-insensitively; its byte length is
+ /// fixed (no multibyte), so slicing past it is safe.
+ fn parse_proposed_goal(bundle: &str) -> Option {
+ const PREFIX: &str = "proposed_goal:";
+ // Boundary-safe prefix match: `get(..len)` returns None rather than
+ // panicking when the line begins with a multibyte char before byte 14.
+ let line = bundle.lines().map(str::trim).find(|l| {
+ l.get(..PREFIX.len())
+ .is_some_and(|p| p.eq_ignore_ascii_case(PREFIX))
+ })?;
+ let value = line[PREFIX.len()..].trim();
+ if value.is_empty() || value.eq_ignore_ascii_case("none") {
+ return None;
+ }
+ Some(value.to_string())
+ }
}
#[async_trait]
@@ -485,6 +556,40 @@ mod tests {
assert!(!prompt.contains("[Focus]"));
}
+ #[test]
+ fn parse_proposed_goal_extracts_objective_or_none() {
+ let bundle = "[context_bundle]\nhas_enough_context: true\n\
+ proposed_goal: Ship the desktop release to all platforms\n\
+ summary: ...\n[/context_bundle]";
+ assert_eq!(
+ AgentPrepareContextTool::parse_proposed_goal(bundle).as_deref(),
+ Some("Ship the desktop release to all platforms")
+ );
+
+ // Explicit `none` → no goal.
+ let none_bundle = "[context_bundle]\nproposed_goal: none\nsummary: x\n[/context_bundle]";
+ assert!(AgentPrepareContextTool::parse_proposed_goal(none_bundle).is_none());
+
+ // Missing line → no goal.
+ let no_line = "[context_bundle]\nhas_enough_context: true\n[/context_bundle]";
+ assert!(AgentPrepareContextTool::parse_proposed_goal(no_line).is_none());
+
+ // Case-insensitive prefix.
+ let cased = "Proposed_Goal: Land the migration ";
+ assert_eq!(
+ AgentPrepareContextTool::parse_proposed_goal(cased).as_deref(),
+ Some("Land the migration")
+ );
+
+ // Lines starting with a multibyte char must not panic the byte-prefix
+ // match (regression for the `l[..14]` non-boundary slice).
+ let multibyte = "[context_bundle]\n日本語の要約 summary line\nproposed_goal: 目標を達成する\n[/context_bundle]";
+ assert_eq!(
+ AgentPrepareContextTool::parse_proposed_goal(multibyte).as_deref(),
+ Some("目標を達成する")
+ );
+ }
+
#[tokio::test]
async fn missing_question_returns_error() {
let tool = AgentPrepareContextTool::new();
diff --git a/src/openhuman/agent_registry/agents/context_scout/prompt.md b/src/openhuman/agent_registry/agents/context_scout/prompt.md
index 8deb09ed3..2430d5d9d 100644
--- a/src/openhuman/agent_registry/agents/context_scout/prompt.md
+++ b/src/openhuman/agent_registry/agents/context_scout/prompt.md
@@ -41,6 +41,10 @@ outside it. No preamble, no closing prose. Use exactly this shape:
```text
[context_bundle]
has_enough_context: true|false
+proposed_goal:
summary: <≤ ~700 tokens of distilled, source-attributed context. Lead with what
matters. Attribute facts: (memory), (transcript: ), (profile),
(web: ), (integrations).>
@@ -62,6 +66,11 @@ Rules for the bundle:
- `has_enough_context` is `true` when the orchestrator could act now without
more gathering; `false` when key facts are still missing (say which in the
summary).
+- `proposed_goal` is the durable objective for the *thread* (not a list of
+ steps). The harness records it as the thread's goal **only if none is set yet**
+ — the orchestrator stays authoritative and may refine it later — so make it a
+ clean, outcome-shaped sentence. Use `none` for chit-chat or a trivial one-shot
+ that doesn't warrant a tracked goal.
- Every `recommended_tool_calls[].tool` MUST be an **exact name** from the
"Orchestrator tools" list injected below — these are the tools the
*orchestrator* can call, not the tools you used. Order them in the sequence
diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml
index 916094fe6..15d0d0ff5 100644
--- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml
+++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml
@@ -223,6 +223,18 @@ named = [
# downstream agents — the orchestrator coordinates, it doesn't
# touch files itself.
"todowrite",
+ # Thread-level goal (Codex-style per-thread completion contract). `goal_set`
+ # records the durable objective for THIS thread when a non-trivial request
+ # lands (the orchestrator is authoritative — it always creates or replaces);
+ # `goal_get` reads it back (status + token budget); `goal_complete` closes it
+ # out only when evidence confirms success. Distinct from `todowrite` (this
+ # thread's task board) and the long-term `goals_*` list (cross-thread). The
+ # harness injects the active goal into context each turn and can auto-continue
+ # it when the thread goes idle, so keeping the objective current here is what
+ # keeps that loop on-target.
+ "goal_set",
+ "goal_get",
+ "goal_complete",
# `update_task` moves/updates a specific task card by id on a target board
# (defaults to the proactive `task-sources` board) — so the orchestrator can
# advance the task it's working: → in_progress when it starts, → done with
diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs
index 6c0309faf..5cae27fc3 100644
--- a/src/openhuman/approval/gate.rs
+++ b/src/openhuman/approval/gate.rs
@@ -298,20 +298,6 @@ impl ApprovalGate {
action_summary: &str,
args_redacted: serde_json::Value,
) -> (GateOutcome, Option) {
- // "Always allow" allowlist shortcut — the user's persisted
- // `autonomy.auto_approve` set. Read from the live policy first so a
- // grant made earlier in this session (which writes config + reloads the
- // live policy) takes effect on the very next tool call; fall back to the
- // gate's boot-time config when no live policy is installed (e.g. a CLI
- // invocation that never started a session runtime, or a unit test).
- if self.tool_is_auto_approved(tool_name) {
- tracing::debug!(
- tool = tool_name,
- "[approval::gate] auto_approve allowlist hit, skipping prompt"
- );
- return (GateOutcome::Allow, None);
- }
-
// Origin tells us who scheduled this turn. Entry points (web channel,
// channel runtime, subconscious, cron, CLI) scope a typed
// `AgentTurnOrigin` around `run_turn`. Unlabelled callers map to
@@ -319,6 +305,32 @@ impl ApprovalGate {
// external_effect tool from an unlabelled call site.
let origin = turn_origin::current().unwrap_or(AgentTurnOrigin::Unknown);
+ // An autonomous goal continuation runs with no user present, so an
+ // irreversible external action must never be auto-allowed — not even via
+ // the `autonomy.auto_approve` allowlist. Skip the shortcut for that
+ // origin and fall through to the parking flow below.
+ let is_goal_continuation = matches!(
+ &origin,
+ AgentTurnOrigin::TrustedAutomation {
+ source: TrustedAutomationSource::GoalContinuation,
+ ..
+ }
+ );
+
+ // "Always allow" allowlist shortcut — the user's persisted
+ // `autonomy.auto_approve` set. Read from the live policy first so a
+ // grant made earlier in this session (which writes config + reloads the
+ // live policy) takes effect on the very next tool call; fall back to the
+ // gate's boot-time config when no live policy is installed (e.g. a CLI
+ // invocation that never started a session runtime, or a unit test).
+ if !is_goal_continuation && self.tool_is_auto_approved(tool_name) {
+ tracing::debug!(
+ tool = tool_name,
+ "[approval::gate] auto_approve allowlist hit, skipping prompt"
+ );
+ return (GateOutcome::Allow, None);
+ }
+
// Chat context (thread/client id) for routing the yes/no reply — set by
// the web channel around the agent run; absent for non-chat callers.
let chat_ctx = APPROVAL_CHAT_CONTEXT.try_with(|c| c.clone()).ok();
@@ -407,6 +419,21 @@ impl ApprovalGate {
None,
);
}
+ AgentTurnOrigin::TrustedAutomation {
+ source: TrustedAutomationSource::GoalContinuation,
+ job_id,
+ } => {
+ tracing::debug!(
+ tool = tool_name,
+ job_id = %job_id,
+ "[approval::gate] autonomous goal continuation — external_effect tool parks \
+ (no present user to authorize); TTL-denies without a routable surface"
+ );
+ // Fall through to the parking flow: an autonomous continuation
+ // runs with no user present, so we must NOT auto-allow an
+ // irreversible external action. Read/compute tools (not gated
+ // here) still make progress on the goal.
+ }
AgentTurnOrigin::Cli => {
tracing::debug!(
tool = tool_name,
diff --git a/src/openhuman/config/schema/heartbeat_cron.rs b/src/openhuman/config/schema/heartbeat_cron.rs
index 5271a64e0..20f09a48a 100644
--- a/src/openhuman/config/schema/heartbeat_cron.rs
+++ b/src/openhuman/config/schema/heartbeat_cron.rs
@@ -126,12 +126,27 @@ pub struct HeartbeatConfig {
/// the always-on loop's spend ceiling.
#[serde(default = "default_max_promotions_per_hour")]
pub max_promotions_per_hour: u32,
+ /// Enable Codex-style autonomous continuation of thread goals: when a thread
+ /// with an `active` goal goes idle (no in-flight turn, no recent activity),
+ /// the heartbeat injects one continuation turn to keep working it. Opt-in
+ /// (default false) because it spends model/integration budget without a user
+ /// present — matching the project's stance on autonomous features.
+ #[serde(default)]
+ pub goal_continuation_enabled: bool,
+ /// Minutes a thread goal must be idle before a continuation turn may fire.
+ /// Clamped to at least 1.
+ #[serde(default = "default_goal_idle_minutes")]
+ pub goal_idle_minutes: u32,
}
fn default_max_promotions_per_hour() -> u32 {
30
}
+fn default_goal_idle_minutes() -> u32 {
+ 10
+}
+
fn default_context_budget() -> u32 {
40_000
}
@@ -179,6 +194,8 @@ impl Default for HeartbeatConfig {
subconscious_mode: SubconsciousMode::Off,
triggers_enabled: false,
max_promotions_per_hour: default_max_promotions_per_hour(),
+ goal_continuation_enabled: false,
+ goal_idle_minutes: default_goal_idle_minutes(),
}
}
}
diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs
index 142480edb..82fe4164f 100644
--- a/src/openhuman/context/manager.rs
+++ b/src/openhuman/context/manager.rs
@@ -205,6 +205,14 @@ impl ContextManager {
self.super_context_enabled
}
+ /// Force-disable the first-turn super-context pass for this session,
+ /// regardless of the config default. Used by non-interactive orchestrator
+ /// builds (e.g. read-only model-council jurors) where a scout pass would add
+ /// an unexpected LLM call and perturb deterministic call sequences.
+ pub fn set_super_context_enabled(&mut self, enabled: bool) {
+ self.super_context_enabled = enabled;
+ }
+
// ─── Budget tracking ──────────────────────────────────────────
/// Feed the latest provider [`UsageInfo`] into the guard + the
diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs
index 5fef2e5fa..8804dc497 100644
--- a/src/openhuman/mod.rs
+++ b/src/openhuman/mod.rs
@@ -113,6 +113,7 @@ pub mod team;
#[cfg(feature = "e2e-test-support")]
pub mod test_support;
pub mod text_input;
+pub mod thread_goals;
pub mod threads;
pub mod tinyplace;
pub mod tls;
diff --git a/src/openhuman/subconscious/heartbeat/engine.rs b/src/openhuman/subconscious/heartbeat/engine.rs
index d8a97626b..994c693e7 100644
--- a/src/openhuman/subconscious/heartbeat/engine.rs
+++ b/src/openhuman/subconscious/heartbeat/engine.rs
@@ -87,6 +87,19 @@ impl HeartbeatEngine {
self.run_event_planner_tick_for_config(&config).await;
+ // Codex-style autonomous continuation of idle thread goals (opt-in
+ // via `heartbeat.goal_continuation_enabled`). Spawned detached, NOT
+ // awaited: a continuation runs a model-backed turn that can be slow
+ // or park on an approval, and must never stall the rest of the
+ // heartbeat tick. Its own process-wide Semaphore(1) prevents
+ // overlapping continuations across ticks.
+ {
+ let cfg = config.clone();
+ tokio::spawn(async move {
+ crate::openhuman::thread_goals::continuation::run_continuation_tick(&cfg).await;
+ });
+ }
+
if current.inference_enabled {
// Get the shared global engine (same instance as RPC handlers)
let lock = match get_or_init_engine().await {
diff --git a/src/openhuman/thread_goals/continuation.rs b/src/openhuman/thread_goals/continuation.rs
new file mode 100644
index 000000000..fc64b3f7c
--- /dev/null
+++ b/src/openhuman/thread_goals/continuation.rs
@@ -0,0 +1,270 @@
+//! Heartbeat-driven autonomous continuation of idle thread goals (Codex's
+//! `MaybeContinueIfIdle`).
+//!
+//! When a thread carries an **active** goal and goes idle — no in-flight turn
+//! and no activity for `goal_idle_minutes` — the heartbeat injects **one**
+//! continuation turn that keeps working the objective. The guards mirror Codex:
+//!
+//! - **Opt-in.** Off unless `heartbeat.goal_continuation_enabled` is set — an
+//! autonomous turn spends budget with no user present, so it defaults off
+//! (matching the project's stance on background automation).
+//! - **One-shot per idle period.** After a continuation fires, the goal is
+//! marked `continuation_suppressed`; a later user-initiated turn clears it
+//! (see [`super::runtime::account_turn_against_goal`]), so the next idle
+//! period can fire again. Prevents a tight self-driving loop.
+//! - **Serialised.** A process-wide `Semaphore(1)` ensures at most one
+//! continuation runs at a time.
+//! - **No in-flight turn.** Skipped while the thread has a live turn.
+//! - **Per-tick cap.** Bounds how many goals continue in a single tick.
+//!
+//! The continuation turn runs the orchestrator on the thread (resuming its
+//! transcript) under a `TrustedAutomation { GoalContinuation }` origin, so the
+//! approval gate parks irreversible external actions (no present user to
+//! authorize) while read/compute work proceeds.
+
+use std::path::Path;
+use std::sync::OnceLock;
+
+use tokio::sync::Semaphore;
+
+use super::store;
+use super::types::{ThreadGoal, ThreadGoalStatus};
+use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
+use crate::openhuman::agent::Agent;
+use crate::openhuman::config::Config;
+use crate::openhuman::inference::provider::thread_context::with_thread_id;
+use crate::openhuman::threads::turn_state::{TurnLifecycle, TurnStateStore};
+
+/// Serialise continuation dispatches so at most one autonomous goal turn runs at
+/// a time (Codex's `Semaphore(1)` guard).
+fn continuation_gate() -> &'static Semaphore {
+ static GATE: OnceLock = OnceLock::new();
+ GATE.get_or_init(|| Semaphore::new(1))
+}
+
+/// Max continuation turns dispatched per heartbeat tick (bounds spend).
+const MAX_CONTINUATIONS_PER_TICK: usize = 2;
+
+/// Current unix time in milliseconds.
+fn now_ms() -> u64 {
+ chrono::Utc::now().timestamp_millis().max(0) as u64
+}
+
+/// One heartbeat pass: find idle active goals and continue them. No-op unless
+/// `heartbeat.goal_continuation_enabled`.
+pub async fn run_continuation_tick(config: &Config) {
+ if !config.heartbeat.goal_continuation_enabled {
+ return;
+ }
+ let workspace_dir = config.workspace_dir.clone();
+ let idle_ms = u64::from(config.heartbeat.goal_idle_minutes.max(1)) * 60_000;
+ let now = now_ms();
+
+ let goals = match store::list_all(&workspace_dir).await {
+ Ok(g) => g,
+ Err(e) => {
+ tracing::warn!("[thread_goals] continuation tick: list_all failed: {e}");
+ return;
+ }
+ };
+
+ let mut candidates: Vec = goals
+ .into_iter()
+ .filter(|g| g.status == ThreadGoalStatus::Active)
+ .filter(|g| !g.continuation_suppressed)
+ .filter(|g| now.saturating_sub(g.updated_at_ms) >= idle_ms)
+ .collect();
+ // Oldest-idle first so the most-neglected goal advances under the per-tick cap.
+ candidates.sort_by_key(|g| g.updated_at_ms);
+ candidates.truncate(MAX_CONTINUATIONS_PER_TICK);
+
+ if candidates.is_empty() {
+ tracing::debug!("[thread_goals] continuation tick: no idle active goals");
+ return;
+ }
+ tracing::info!(
+ "[thread_goals] continuation tick: {} idle candidate(s) (idle_min={})",
+ candidates.len(),
+ config.heartbeat.goal_idle_minutes.max(1)
+ );
+
+ for goal in candidates {
+ if has_in_flight_turn(&workspace_dir, &goal.thread_id) {
+ tracing::debug!(
+ thread_id = %goal.thread_id,
+ "[thread_goals] continuation skip: turn in flight"
+ );
+ continue;
+ }
+
+ // One continuation at a time. If the gate is busy, another continuation
+ // is mid-flight — defer the rest to the next tick.
+ let permit = match continuation_gate().try_acquire() {
+ Ok(p) => p,
+ Err(_) => {
+ tracing::debug!("[thread_goals] continuation gate busy; deferring to next tick");
+ return;
+ }
+ };
+
+ let ran = dispatch_continuation(config, &goal).await;
+
+ // One-shot: suppress further continuations on this goal until a
+ // user-initiated turn clears the flag. Only when the turn actually ran,
+ // and only if THIS goal is still current (compare-and-set on goal_id) —
+ // a goal completed or replaced during the turn must not be suppressed.
+ if ran {
+ if let Err(e) = store::set_continuation_suppressed_if(
+ &workspace_dir,
+ &goal.thread_id,
+ &goal.goal_id,
+ true,
+ )
+ .await
+ {
+ tracing::debug!(
+ thread_id = %goal.thread_id,
+ error = %e,
+ "[thread_goals] failed to set continuation_suppressed"
+ );
+ }
+ }
+ drop(permit);
+ }
+}
+
+/// Whether `thread_id` currently has a live (started/streaming) turn.
+fn has_in_flight_turn(workspace_dir: &Path, thread_id: &str) -> bool {
+ match TurnStateStore::new(workspace_dir.to_path_buf()).get(thread_id) {
+ Ok(Some(ts)) => matches!(
+ ts.lifecycle,
+ TurnLifecycle::Started | TurnLifecycle::Streaming
+ ),
+ _ => false,
+ }
+}
+
+/// The continuation message handed to the orchestrator.
+fn continuation_prompt(objective: &str) -> String {
+ format!(
+ "[goal continuation] You are resuming autonomous work toward this thread's goal — \
+ no user is currently present.\n\nGoal: {objective}\n\n\
+ Assess progress against concrete evidence, then take the next useful step. \
+ If the goal is already satisfied, call `goal_complete`. If you are blocked or \
+ need the user (e.g. an irreversible external action, missing input), stop and \
+ summarise the blocker and the next step rather than guessing — external actions \
+ are not auto-approved while you run unattended."
+ )
+}
+
+/// Build and run a single continuation turn for `goal`. Best-effort: failures
+/// are logged, never propagated (the heartbeat must keep ticking).
+///
+/// Returns `true` when a turn was actually attempted (agent built + run_single
+/// invoked), `false` when the agent couldn't even be built — the caller only
+/// suppresses further continuations when a turn actually ran.
+async fn dispatch_continuation(config: &Config, goal: &ThreadGoal) -> bool {
+ let thread_id = goal.thread_id.clone();
+ tracing::info!(
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ "[thread_goals] dispatching continuation turn"
+ );
+
+ let mut agent = match Agent::from_config_for_agent(config, "orchestrator") {
+ Ok(a) => a,
+ Err(e) => {
+ tracing::warn!(
+ thread_id = %thread_id,
+ error = %e,
+ "[thread_goals] continuation: failed to build orchestrator agent"
+ );
+ return false;
+ }
+ };
+ // Tag events so subscribers can correlate goal-continuation turns and filter
+ // them from user-driven flows.
+ agent.set_event_context(format!("goal:{thread_id}"), "goal_continuation");
+
+ let prompt = continuation_prompt(&goal.objective);
+ let origin = AgentTurnOrigin::TrustedAutomation {
+ job_id: format!("goal:{thread_id}"),
+ source: TrustedAutomationSource::GoalContinuation,
+ };
+
+ // Scope the ambient thread id (so the goal tools + per-turn injection target
+ // this thread) and the trusted-automation origin (so the approval gate parks
+ // unattended external actions). `run_single` resumes the thread transcript.
+ let result = with_thread_id(
+ thread_id.clone(),
+ with_origin(origin, agent.run_single(&prompt)),
+ )
+ .await;
+
+ match result {
+ Ok(text) => tracing::info!(
+ thread_id = %thread_id,
+ response_chars = text.chars().count(),
+ "[thread_goals] continuation turn complete"
+ ),
+ Err(e) => tracing::warn!(
+ thread_id = %thread_id,
+ error = %e,
+ "[thread_goals] continuation turn failed"
+ ),
+ }
+ // A turn was attempted (built + run) regardless of Ok/Err — suppress so we
+ // don't re-fire it every tick until the user re-engages.
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn config_with(enabled: bool, idle_minutes: u32, workspace: &Path) -> Config {
+ let mut config = Config::default();
+ config.workspace_dir = workspace.to_path_buf();
+ config.heartbeat.goal_continuation_enabled = enabled;
+ config.heartbeat.goal_idle_minutes = idle_minutes;
+ config
+ }
+
+ #[tokio::test]
+ async fn tick_is_noop_when_disabled() {
+ let tmp = tempfile::tempdir().unwrap();
+ // Active, long-idle goal present — but the feature is off.
+ store::set(tmp.path(), "t", "obj", None).await.unwrap();
+ let config = config_with(false, 1, tmp.path());
+ // Must not panic / must return promptly; nothing to assert beyond no-op.
+ run_continuation_tick(&config).await;
+ // Goal untouched (not suppressed) since the feature is disabled.
+ let g = store::get(tmp.path(), "t").await.unwrap().unwrap();
+ assert!(!g.continuation_suppressed);
+ }
+
+ #[tokio::test]
+ async fn candidate_filter_respects_status_idle_and_suppression() {
+ // This exercises the selection predicate without dispatching a turn by
+ // keeping the feature enabled but pointing at a fresh (non-idle) goal.
+ let tmp = tempfile::tempdir().unwrap();
+ // Fresh goal: updated_at is "now", so with a 60-min idle window it is
+ // NOT a candidate and the tick stays a no-op (no agent build attempted).
+ store::set(tmp.path(), "fresh", "obj", None).await.unwrap();
+ let config = config_with(true, 60, tmp.path());
+ run_continuation_tick(&config).await;
+ let g = store::get(tmp.path(), "fresh").await.unwrap().unwrap();
+ assert!(
+ !g.continuation_suppressed,
+ "fresh goal must not be continued/suppressed"
+ );
+ }
+
+ #[test]
+ fn continuation_prompt_names_objective_and_guards() {
+ let p = continuation_prompt("ship the release");
+ assert!(p.contains("ship the release"));
+ assert!(p.contains("goal_complete"));
+ assert!(p.contains("not auto-approved"));
+ }
+}
diff --git a/src/openhuman/thread_goals/mod.rs b/src/openhuman/thread_goals/mod.rs
new file mode 100644
index 000000000..5ec66b92f
--- /dev/null
+++ b/src/openhuman/thread_goals/mod.rs
@@ -0,0 +1,33 @@
+//! `thread_goals` — the agent's single, thread-scoped goal.
+//!
+//! A **thread goal** is a durable "completion contract" the agent keeps
+//! pursuing across turns, interrupts, resumes, and budget boundaries — modelled
+//! on OpenAI Codex's `/goal`. It is deliberately distinct from the two existing
+//! "goals" concepts:
+//!
+//! - [`memory_goals`](crate::openhuman::memory_goals) — a *global*, long-term
+//! list of the user's durable goals (`MEMORY_GOALS.md`).
+//! - the per-thread kanban [task board](crate::openhuman::agent::task_board) —
+//! a list of work cards.
+//!
+//! There is **exactly one** thread goal per thread, with a small lifecycle
+//! (active / paused / budget_limited / complete), an optional token budget, and
+//! support for autonomous idle continuation.
+//!
+//! Persistence is per-thread file-JSON under
+//! `/thread_goals/.json` (see [`store`]). Two writers
+//! may set it when a chat begins: the orchestrator (authoritative, via
+//! `goal_set`) and the context-gathering path (proposes only if absent, via
+//! [`store::set_if_absent`]).
+
+pub mod continuation;
+pub mod ops;
+pub mod runtime;
+mod schemas;
+pub mod store;
+pub mod tools;
+pub mod types;
+
+pub use schemas::{all_thread_goals_controller_schemas, all_thread_goals_registered_controllers};
+pub use tools::{GoalCompleteTool, GoalGetTool, GoalSetTool};
+pub use types::{ThreadGoal, ThreadGoalStatus};
diff --git a/src/openhuman/thread_goals/ops.rs b/src/openhuman/thread_goals/ops.rs
new file mode 100644
index 000000000..9a2b0bba7
--- /dev/null
+++ b/src/openhuman/thread_goals/ops.rs
@@ -0,0 +1,165 @@
+//! Business logic for the thread-goal domain — thin handlers over
+//! [`super::store`], each returning an [`RpcOutcome`] so the RPC layer and CLI
+//! share a uniform shape with logs. Mutating ops emit `thread/goal/updated` (or
+//! `thread/goal/cleared`) domain events so the desktop UI can live-update.
+
+use std::path::Path;
+
+use serde::Serialize;
+
+use super::store;
+use super::types::ThreadGoal;
+use crate::core::event_bus::{publish_global, DomainEvent};
+use crate::rpc::RpcOutcome;
+
+/// Envelope returned by reads/clears where the goal may be absent.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GoalEnvelope {
+ /// The current goal, or `null` when the thread has none.
+ pub goal: Option,
+}
+
+/// Result of a clear operation.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ClearResult {
+ /// Whether a goal existed and was removed.
+ pub removed: bool,
+}
+
+/// Publish a `thread/goal/updated` event for live UI sync (best-effort).
+fn emit_updated(goal: &ThreadGoal) {
+ publish_global(DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ });
+}
+
+/// Get the goal for a thread (or `null`).
+pub async fn get(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=get thread_id={thread_id}");
+ let goal = store::get(workspace_dir, thread_id).await?;
+ Ok(RpcOutcome::new(GoalEnvelope { goal }, vec![]))
+}
+
+/// Create or replace the goal for a thread.
+pub async fn set(
+ workspace_dir: &Path,
+ thread_id: &str,
+ objective: &str,
+ token_budget: Option,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=set thread_id={thread_id}");
+ let goal = store::set(workspace_dir, thread_id, objective, token_budget).await?;
+ emit_updated(&goal);
+ Ok(RpcOutcome::single_log(
+ GoalEnvelope {
+ goal: Some(goal.clone()),
+ },
+ format!(
+ "set thread goal {} ({})",
+ goal.goal_id,
+ goal.status.as_str()
+ ),
+ ))
+}
+
+/// Mark the goal complete.
+pub async fn complete(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=complete thread_id={thread_id}");
+ let goal = store::complete(workspace_dir, thread_id).await?;
+ emit_updated(&goal);
+ Ok(RpcOutcome::single_log(
+ GoalEnvelope {
+ goal: Some(goal.clone()),
+ },
+ format!("completed thread goal {}", goal.goal_id),
+ ))
+}
+
+/// Pause an active goal.
+pub async fn pause(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=pause thread_id={thread_id}");
+ let goal = store::pause(workspace_dir, thread_id).await?;
+ emit_updated(&goal);
+ Ok(RpcOutcome::single_log(
+ GoalEnvelope {
+ goal: Some(goal.clone()),
+ },
+ format!("paused thread goal {}", goal.goal_id),
+ ))
+}
+
+/// Resume a paused goal.
+pub async fn resume(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=resume thread_id={thread_id}");
+ let goal = store::resume(workspace_dir, thread_id).await?;
+ emit_updated(&goal);
+ Ok(RpcOutcome::single_log(
+ GoalEnvelope {
+ goal: Some(goal.clone()),
+ },
+ format!("resumed thread goal {}", goal.goal_id),
+ ))
+}
+
+/// Clear (delete) the goal for a thread.
+pub async fn clear(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Result, String> {
+ log::debug!("[thread_goals] rpc=clear thread_id={thread_id}");
+ let removed = store::clear(workspace_dir, thread_id).await?;
+ if removed {
+ publish_global(DomainEvent::ThreadGoalCleared {
+ thread_id: thread_id.to_string(),
+ });
+ }
+ Ok(RpcOutcome::single_log(
+ ClearResult { removed },
+ format!("cleared thread goal (removed={removed})"),
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn set_get_complete_clear_flow() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+
+ // Empty to start.
+ let got = get(dir, "t").await.unwrap();
+ assert!(got.value.goal.is_none());
+
+ // Set.
+ let set_out = set(dir, "t", "ship it", Some(1000)).await.unwrap();
+ let goal = set_out.value.goal.unwrap();
+ assert_eq!(goal.objective, "ship it");
+
+ // Complete.
+ let done = complete(dir, "t").await.unwrap();
+ assert_eq!(done.value.goal.unwrap().status.as_str(), "complete");
+
+ // Clear.
+ let cleared = clear(dir, "t").await.unwrap();
+ assert!(cleared.value.removed);
+ assert!(get(dir, "t").await.unwrap().value.goal.is_none());
+ }
+}
diff --git a/src/openhuman/thread_goals/runtime.rs b/src/openhuman/thread_goals/runtime.rs
new file mode 100644
index 000000000..13ab5cf54
--- /dev/null
+++ b/src/openhuman/thread_goals/runtime.rs
@@ -0,0 +1,424 @@
+//! Harness-level runtime for the thread goal: per-turn context injection,
+//! token-budget accounting, and the mid-turn budget stop hook.
+//!
+//! These are the pieces that make a stored goal actually steer the agent
+//! (Codex parity):
+//!
+//! - [`active_goal_context_block`] renders a compact `[active_goal]` block that
+//! the turn loop prepends to the user message **fresh each turn** (never the
+//! cached system-prompt prefix), so the objective stays visible and the model
+//! sees live budget/status.
+//! - [`account_turn_against_goal`] folds a completed turn's token + time usage
+//! into the active goal, flipping it to `budget_limited` when the cap is
+//! crossed.
+//! - [`GoalBudgetStopHook`] hard-stops an in-flight turn the moment an *active*
+//! goal's running usage would exceed its budget, so an autonomous run can't
+//! blow past the ceiling between accounting points.
+
+use std::path::{Path, PathBuf};
+
+use async_trait::async_trait;
+
+use super::store;
+use super::types::{ThreadGoal, ThreadGoalStatus};
+use crate::core::event_bus::{publish_global, DomainEvent};
+use crate::openhuman::agent::stop_hooks::{StopDecision, StopHook, TurnState};
+use crate::openhuman::inference::provider::thread_context::current_thread_id;
+
+/// Load the goal for the ambient chat thread, if any. Returns `None` outside a
+/// thread scope (CLI / background paths) or when the thread has no goal.
+pub async fn load_for_current_thread(workspace_dir: &Path) -> Option {
+ let thread_id = current_thread_id()?;
+ match store::get(workspace_dir, &thread_id).await {
+ Ok(goal) => goal,
+ Err(e) => {
+ tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] load_for_current_thread failed");
+ None
+ }
+ }
+}
+
+/// Reactivate a paused goal for the ambient thread (thread-resume semantics).
+/// Returns the updated goal, or `None` outside a thread scope / when absent.
+/// Best-effort: a failure is logged and surfaced as `Ok(None)`-style `None`.
+pub async fn resume_for_current_thread(workspace_dir: &Path) -> Option> {
+ let thread_id = current_thread_id()?;
+ match store::resume(workspace_dir, &thread_id).await {
+ Ok(goal) => {
+ if goal.status.is_active() {
+ publish_global(DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ });
+ }
+ Some(Some(goal))
+ }
+ Err(e) => {
+ tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] resume_for_current_thread failed");
+ None
+ }
+ }
+}
+
+/// Pause the active goal for the ambient thread (interrupt/abort semantics).
+/// Best-effort; safe to call when there is no goal or no thread scope.
+pub async fn pause_for_current_thread(workspace_dir: &Path) {
+ let Some(thread_id) = current_thread_id() else {
+ return;
+ };
+ match store::pause(workspace_dir, &thread_id).await {
+ Ok(goal) => {
+ if matches!(goal.status, ThreadGoalStatus::Paused) {
+ publish_global(DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ });
+ }
+ }
+ Err(e) => {
+ tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] pause_for_current_thread failed");
+ }
+ }
+}
+
+/// Render the per-turn `[active_goal]` context block for `goal`, or `None` when
+/// the goal is in a state that needs no steering text.
+///
+/// The block is intentionally tiny and source-attributed so it reads as harness
+/// state, not user instruction.
+pub fn active_goal_context_block(goal: &ThreadGoal) -> Option {
+ let directive = match goal.status {
+ ThreadGoalStatus::Active => {
+ "Keep working toward this goal. When evidence confirms it's done, call \
+ goal_complete; if the objective has changed, update it with goal_set."
+ }
+ ThreadGoalStatus::BudgetLimited => {
+ "This goal has reached its token budget. Stop substantive work: summarise \
+ progress and blockers, and name the next useful step. Do not continue \
+ until the user raises the budget or clears the goal."
+ }
+ // A paused goal isn't being worked right now; a completed goal needs no
+ // steering. Surfacing them would only add noise to the turn.
+ ThreadGoalStatus::Paused | ThreadGoalStatus::Complete => return None,
+ };
+ let budget = match (goal.token_budget, goal.budget_remaining()) {
+ (Some(b), Some(rem)) => format!(
+ "\nbudget: {} used / {b} ({rem} remaining)",
+ goal.tokens_used
+ ),
+ _ => String::new(),
+ };
+ Some(format!(
+ "[active_goal]\nstatus: {}\nobjective: {}{}\n{}\n[/active_goal]\n\n",
+ goal.status.as_str(),
+ goal.objective,
+ budget,
+ directive
+ ))
+}
+
+/// The per-turn token total used for budget accounting (prompt + completion).
+fn turn_tokens(input: u64, output: u64) -> u64 {
+ input.saturating_add(output)
+}
+
+/// Whether the current turn is an autonomous goal-continuation (vs. a
+/// user-initiated turn). Used so a continuation doesn't clear its own one-shot
+/// suppression flag.
+fn is_goal_continuation_turn() -> bool {
+ matches!(
+ crate::openhuman::agent::turn_origin::current(),
+ Some(
+ crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation {
+ source:
+ crate::openhuman::agent::turn_origin::TrustedAutomationSource::GoalContinuation,
+ ..
+ }
+ )
+ )
+}
+
+/// Account a finished turn's usage against the ambient thread's goal.
+///
+/// Only **active** goals are charged (a paused/complete/budget-limited goal
+/// doesn't accrue usage from incidental chat). Best-effort: a failure is logged
+/// and swallowed so accounting never fails a user turn. Emits
+/// `ThreadGoalUpdated` when the status changes (e.g. → `budget_limited`) so the
+/// UI chip refreshes.
+pub async fn account_turn_against_goal(workspace_dir: &Path, input: u64, output: u64, secs: u64) {
+ let Some(thread_id) = current_thread_id() else {
+ return;
+ };
+ let goal = match store::get(workspace_dir, &thread_id).await {
+ Ok(Some(g)) => g,
+ Ok(None) => return,
+ Err(e) => {
+ tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] account get failed");
+ return;
+ }
+ };
+ if !goal.status.is_active() {
+ return;
+ }
+ // Reset the one-shot continuation suppression on user-initiated activity: a
+ // real turn in this thread means the user re-engaged, so a future idle
+ // period may auto-continue again. The continuation turn itself runs under a
+ // GoalContinuation origin and must NOT clear its own suppression.
+ if goal.continuation_suppressed && !is_goal_continuation_turn() {
+ if let Err(e) = store::set_continuation_suppressed(workspace_dir, &thread_id, false).await {
+ tracing::debug!(
+ thread_id = %thread_id,
+ error = %e,
+ "[thread_goals] failed to clear continuation suppression"
+ );
+ }
+ }
+ let delta = turn_tokens(input, output);
+ if delta == 0 && secs == 0 {
+ return;
+ }
+ let prev_status = goal.status;
+ match store::account_usage(workspace_dir, &thread_id, &goal.goal_id, delta, secs).await {
+ Ok(Some(updated)) => {
+ tracing::debug!(
+ thread_id = %thread_id,
+ goal_id = %updated.goal_id,
+ tokens_used = updated.tokens_used,
+ status = updated.status.as_str(),
+ "[thread_goals] accounted turn usage (+{delta} tok, +{secs}s)"
+ );
+ if updated.status != prev_status {
+ publish_global(DomainEvent::ThreadGoalUpdated {
+ thread_id: updated.thread_id.clone(),
+ goal_id: updated.goal_id.clone(),
+ status: updated.status.as_str().to_string(),
+ });
+ }
+ }
+ Ok(None) => {}
+ Err(e) => {
+ tracing::debug!(thread_id = %thread_id, error = %e, "[thread_goals] account_usage failed");
+ }
+ }
+}
+
+/// Mid-turn stop hook that halts an in-flight turn once an **active** goal's
+/// running usage (already-accounted tokens from prior turns + this turn's
+/// tokens so far) would meet or exceed its budget.
+///
+/// It only fires for goals that are still `Active` with a configured budget —
+/// once a goal is `budget_limited`/`paused`/`complete` the user can still chat
+/// freely (the injected context steers the model to summarise), so we never
+/// hard-stop a user-present turn that isn't actively burning a live budget.
+#[derive(Debug, Clone)]
+pub struct GoalBudgetStopHook {
+ workspace_dir: PathBuf,
+ thread_id: String,
+ /// The goal version this hook was armed for. Stops enforcing if the goal is
+ /// replaced mid-turn (a new objective mints a new id).
+ goal_id: String,
+ budget: u64,
+}
+
+impl GoalBudgetStopHook {
+ /// Build a hook for `goal` if it's active and has a budget; `None` otherwise.
+ pub fn for_goal(workspace_dir: &Path, goal: &ThreadGoal) -> Option {
+ if !goal.status.is_active() {
+ return None;
+ }
+ let budget = goal.token_budget?;
+ Some(Self {
+ workspace_dir: workspace_dir.to_path_buf(),
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ budget,
+ })
+ }
+}
+
+#[async_trait]
+impl StopHook for GoalBudgetStopHook {
+ fn name(&self) -> &str {
+ "thread_goal_budget"
+ }
+
+ async fn check(&self, ctx: &TurnState<'_>) -> StopDecision {
+ // Read the goal's already-accounted usage (prior turns). If it's gone,
+ // replaced, or no longer active, stop enforcing.
+ let goal = match store::get(&self.workspace_dir, &self.thread_id).await {
+ Ok(Some(g)) => g,
+ _ => return StopDecision::Continue,
+ };
+ if goal.goal_id != self.goal_id || !goal.status.is_active() {
+ return StopDecision::Continue;
+ }
+ let projected = goal
+ .tokens_used
+ .saturating_add(turn_tokens(ctx.cost.input_tokens, ctx.cost.output_tokens));
+ if projected >= self.budget {
+ StopDecision::Stop {
+ reason: format!(
+ "thread goal budget reached: {projected} tokens >= {} budget — stopping to summarise progress",
+ self.budget
+ ),
+ }
+ } else {
+ StopDecision::Continue
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::agent::cost::TurnCost;
+ use crate::openhuman::inference::provider::UsageInfo;
+
+ fn cost_with_tokens(input: u64, output: u64) -> TurnCost {
+ let mut tc = TurnCost::new();
+ tc.add_call(
+ "agentic-v1",
+ &UsageInfo {
+ input_tokens: input,
+ output_tokens: output,
+ ..Default::default()
+ },
+ );
+ tc
+ }
+
+ #[test]
+ fn active_block_includes_objective_and_budget() {
+ let goal = ThreadGoal {
+ thread_id: "t".into(),
+ goal_id: "g".into(),
+ objective: "ship the feature".into(),
+ status: ThreadGoalStatus::Active,
+ token_budget: Some(1000),
+ tokens_used: 250,
+ time_used_seconds: 0,
+ created_at_ms: 0,
+ updated_at_ms: 0,
+ continuation_suppressed: false,
+ };
+ let block = active_goal_context_block(&goal).unwrap();
+ assert!(block.contains("[active_goal]"));
+ assert!(block.contains("ship the feature"));
+ assert!(block.contains("250 used / 1000"));
+ assert!(block.contains("goal_complete"));
+ }
+
+ #[test]
+ fn budget_limited_block_steers_to_summarise() {
+ let goal = ThreadGoal {
+ thread_id: "t".into(),
+ goal_id: "g".into(),
+ objective: "obj".into(),
+ status: ThreadGoalStatus::BudgetLimited,
+ token_budget: Some(100),
+ tokens_used: 100,
+ time_used_seconds: 0,
+ created_at_ms: 0,
+ updated_at_ms: 0,
+ continuation_suppressed: false,
+ };
+ let block = active_goal_context_block(&goal).unwrap();
+ assert!(block.contains("reached its token budget"));
+ }
+
+ #[test]
+ fn paused_and_complete_render_no_block() {
+ let mut goal = ThreadGoal {
+ thread_id: "t".into(),
+ goal_id: "g".into(),
+ objective: "obj".into(),
+ status: ThreadGoalStatus::Paused,
+ token_budget: None,
+ tokens_used: 0,
+ time_used_seconds: 0,
+ created_at_ms: 0,
+ updated_at_ms: 0,
+ continuation_suppressed: false,
+ };
+ assert!(active_goal_context_block(&goal).is_none());
+ goal.status = ThreadGoalStatus::Complete;
+ assert!(active_goal_context_block(&goal).is_none());
+ }
+
+ #[tokio::test]
+ async fn account_turn_charges_active_goal_and_trips_budget() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ crate::openhuman::inference::provider::thread_context::with_thread_id("t-acct", async {
+ store::set(&dir, "t-acct", "obj", Some(100)).await.unwrap();
+ account_turn_against_goal(&dir, 80, 40, 3).await; // 120 >= 100
+ let g = store::get(&dir, "t-acct").await.unwrap().unwrap();
+ assert_eq!(g.tokens_used, 120);
+ assert_eq!(g.status, ThreadGoalStatus::BudgetLimited);
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn account_turn_skips_non_active_goal() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ crate::openhuman::inference::provider::thread_context::with_thread_id("t-paused", async {
+ store::set(&dir, "t-paused", "obj", Some(1000))
+ .await
+ .unwrap();
+ store::pause(&dir, "t-paused").await.unwrap();
+ account_turn_against_goal(&dir, 500, 500, 1).await;
+ let g = store::get(&dir, "t-paused").await.unwrap().unwrap();
+ assert_eq!(g.tokens_used, 0, "paused goal must not accrue usage");
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn budget_stop_hook_fires_on_crossing() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ let goal = store::set(&dir, "t-hook", "obj", Some(1000)).await.unwrap();
+ // 600 already used in a prior turn.
+ store::account_usage(&dir, "t-hook", &goal.goal_id, 600, 0)
+ .await
+ .unwrap();
+ let goal = store::get(&dir, "t-hook").await.unwrap().unwrap();
+ let hook = GoalBudgetStopHook::for_goal(&dir, &goal).expect("budgeted active goal");
+
+ // This turn so far: 300 in + 200 out = 500. 600 + 500 = 1100 >= 1000.
+ let cost = cost_with_tokens(300, 200);
+ let ctx = TurnState {
+ iteration: 2,
+ max_iterations: 10,
+ cost: &cost,
+ model: "agentic-v1",
+ };
+ assert!(matches!(hook.check(&ctx).await, StopDecision::Stop { .. }));
+
+ // Under the cap continues.
+ let small = cost_with_tokens(100, 100); // 600 + 200 = 800 < 1000
+ let ctx2 = TurnState {
+ iteration: 1,
+ max_iterations: 10,
+ cost: &small,
+ model: "agentic-v1",
+ };
+ assert!(matches!(hook.check(&ctx2).await, StopDecision::Continue));
+ }
+
+ #[tokio::test]
+ async fn no_hook_without_budget_or_when_inactive() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ let no_budget = store::set(&dir, "a", "obj", None).await.unwrap();
+ assert!(GoalBudgetStopHook::for_goal(&dir, &no_budget).is_none());
+ store::set(&dir, "b", "obj", Some(100)).await.unwrap();
+ store::pause(&dir, "b").await.unwrap();
+ let paused = store::get(&dir, "b").await.unwrap().unwrap();
+ assert!(GoalBudgetStopHook::for_goal(&dir, &paused).is_none());
+ }
+}
diff --git a/src/openhuman/thread_goals/schemas.rs b/src/openhuman/thread_goals/schemas.rs
new file mode 100644
index 000000000..2fb0efbc9
--- /dev/null
+++ b/src/openhuman/thread_goals/schemas.rs
@@ -0,0 +1,248 @@
+//! Controller schemas + JSON-RPC handlers for the `thread_goals` namespace.
+//!
+//! Methods are exposed as `openhuman.thread_goals_`:
+//! `get`, `set`, `complete`, `pause`, `resume`, `clear`. Handlers load the
+//! active config (for `workspace_dir`), delegate to [`super::ops`], and
+//! serialise the [`RpcOutcome`] into the CLI-compatible JSON shape.
+
+use serde::de::DeserializeOwned;
+use serde_json::{Map, Value};
+
+use super::ops;
+use crate::core::all::{ControllerFuture, RegisteredController};
+use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
+use crate::openhuman::config::rpc as config_rpc;
+use crate::rpc::RpcOutcome;
+
+/// All `thread_goals` controller schemas (advertised to CLI + RPC consumers).
+pub fn all_thread_goals_controller_schemas() -> Vec {
+ FUNCTIONS.iter().map(|f| schemas(f)).collect()
+}
+
+/// Registered `thread_goals` controllers (schema + handler pairs).
+pub fn all_thread_goals_registered_controllers() -> Vec {
+ vec![
+ RegisteredController {
+ schema: schemas("get"),
+ handler: handle_get,
+ },
+ RegisteredController {
+ schema: schemas("set"),
+ handler: handle_set,
+ },
+ RegisteredController {
+ schema: schemas("complete"),
+ handler: handle_complete,
+ },
+ RegisteredController {
+ schema: schemas("pause"),
+ handler: handle_pause,
+ },
+ RegisteredController {
+ schema: schemas("resume"),
+ handler: handle_resume,
+ },
+ RegisteredController {
+ schema: schemas("clear"),
+ handler: handle_clear,
+ },
+ ]
+}
+
+const FUNCTIONS: &[&str] = &["get", "set", "complete", "pause", "resume", "clear"];
+
+fn thread_id_input() -> FieldSchema {
+ FieldSchema {
+ name: "thread_id",
+ ty: TypeSchema::String,
+ comment: "The conversation thread the goal belongs to.",
+ required: true,
+ }
+}
+
+fn goal_output() -> FieldSchema {
+ FieldSchema {
+ name: "result",
+ ty: TypeSchema::Json,
+ comment: "{ goal } — the current thread goal (or null when absent).",
+ required: true,
+ }
+}
+
+/// Schema definitions for every `thread_goals` function.
+fn schemas(function: &str) -> ControllerSchema {
+ match function {
+ "get" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "get",
+ description: "Get the thread-level goal for a thread (or null).",
+ inputs: vec![thread_id_input()],
+ outputs: vec![goal_output()],
+ },
+ "set" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "set",
+ description: "Create or replace the thread-level goal. Changing the objective \
+ mints a new goal id and resets usage counters.",
+ inputs: vec![
+ thread_id_input(),
+ FieldSchema {
+ name: "objective",
+ ty: TypeSchema::String,
+ comment: "The durable objective the agent should keep pursuing.",
+ required: true,
+ },
+ FieldSchema {
+ name: "token_budget",
+ ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
+ comment:
+ "Optional token ceiling; exceeding it flips the goal to budget_limited.",
+ required: false,
+ },
+ ],
+ outputs: vec![goal_output()],
+ },
+ "complete" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "complete",
+ description: "Mark the thread goal complete.",
+ inputs: vec![thread_id_input()],
+ outputs: vec![goal_output()],
+ },
+ "pause" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "pause",
+ description: "Pause an active thread goal.",
+ inputs: vec![thread_id_input()],
+ outputs: vec![goal_output()],
+ },
+ "resume" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "resume",
+ description: "Resume a paused thread goal.",
+ inputs: vec![thread_id_input()],
+ outputs: vec![goal_output()],
+ },
+ "clear" => ControllerSchema {
+ namespace: "thread_goals",
+ function: "clear",
+ description: "Clear (delete) the thread goal.",
+ inputs: vec![thread_id_input()],
+ outputs: vec![FieldSchema {
+ name: "result",
+ ty: TypeSchema::Json,
+ comment: "{ removed } — whether a goal existed and was removed.",
+ required: true,
+ }],
+ },
+ other => panic!("unknown thread_goals function: {other}"),
+ }
+}
+
+// ── Handlers ─────────────────────────────────────────────────────────────
+
+fn handle_get(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(ops::get(&config.workspace_dir, &req.thread_id).await?)
+ })
+}
+
+fn handle_set(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(
+ ops::set(
+ &config.workspace_dir,
+ &req.thread_id,
+ &req.objective,
+ req.token_budget,
+ )
+ .await?,
+ )
+ })
+}
+
+fn handle_complete(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(ops::complete(&config.workspace_dir, &req.thread_id).await?)
+ })
+}
+
+fn handle_pause(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(ops::pause(&config.workspace_dir, &req.thread_id).await?)
+ })
+}
+
+fn handle_resume(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(ops::resume(&config.workspace_dir, &req.thread_id).await?)
+ })
+}
+
+fn handle_clear(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ let req = parse_value::(Value::Object(params))?;
+ to_json(ops::clear(&config.workspace_dir, &req.thread_id).await?)
+ })
+}
+
+// ── Param structs + helpers ──────────────────────────────────────────────
+
+#[derive(serde::Deserialize)]
+struct ThreadIdParams {
+ thread_id: String,
+}
+
+#[derive(serde::Deserialize)]
+struct SetParams {
+ thread_id: String,
+ objective: String,
+ #[serde(default)]
+ token_budget: Option,
+}
+
+fn parse_value(v: Value) -> Result {
+ serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
+}
+
+fn to_json(outcome: RpcOutcome) -> Result {
+ outcome.into_cli_compatible_json()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn registers_all_controllers() {
+ let controllers = all_thread_goals_registered_controllers();
+ assert_eq!(controllers.len(), FUNCTIONS.len());
+ let methods: Vec = controllers
+ .iter()
+ .map(|c| format!("{}.{}", c.schema.namespace, c.schema.function))
+ .collect();
+ for f in FUNCTIONS {
+ let expected = format!("thread_goals.{f}");
+ assert!(methods.contains(&expected), "missing {expected}");
+ }
+ }
+
+ #[test]
+ fn schemas_and_controllers_stay_in_sync() {
+ assert_eq!(
+ all_thread_goals_controller_schemas().len(),
+ all_thread_goals_registered_controllers().len()
+ );
+ }
+}
diff --git a/src/openhuman/thread_goals/store.rs b/src/openhuman/thread_goals/store.rs
new file mode 100644
index 000000000..ae4f5cae9
--- /dev/null
+++ b/src/openhuman/thread_goals/store.rs
@@ -0,0 +1,617 @@
+//! Persistence for the thread-level goal.
+//!
+//! Each thread's goal lives in a single JSON file at
+//! `/thread_goals/.json` — the same per-thread
+//! file-JSON pattern as the kanban task board
+//! ([`crate::openhuman::agent::task_board`]). There is at most one goal per
+//! thread.
+//!
+//! All mutations go through a load → mutate → atomic-persist sequence,
+//! serialised by a process-wide mutex so concurrent writers (agent tools, RPC,
+//! the heartbeat continuation runtime, and post-turn accounting) can't clobber
+//! each other.
+
+use std::fs;
+use std::io::{Read as _, Write as _};
+use std::path::{Path, PathBuf};
+use std::sync::OnceLock;
+
+use chrono::Utc;
+use tokio::sync::Mutex;
+
+use super::types::{ThreadGoal, ThreadGoalStatus};
+
+const THREAD_GOALS_DIR: &str = "thread_goals";
+const THREAD_GOALS_EXTENSION: &str = "json";
+
+/// Serialises load→mutate→save sequences across all callers.
+fn goal_mutation_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+}
+
+/// Current unix time in milliseconds.
+fn now_ms() -> u64 {
+ Utc::now().timestamp_millis().max(0) as u64
+}
+
+fn validate_thread_id(thread_id: &str) -> Result {
+ let trimmed = thread_id.trim();
+ if trimmed.is_empty() {
+ return Err("invalid thread goal thread_id: empty or whitespace".to_string());
+ }
+ Ok(trimmed.to_string())
+}
+
+/// On-disk store rooted at the user's `workspace_dir`.
+#[derive(Debug, Clone)]
+pub struct ThreadGoalStore {
+ workspace_dir: PathBuf,
+}
+
+impl ThreadGoalStore {
+ pub fn new(workspace_dir: PathBuf) -> Self {
+ Self { workspace_dir }
+ }
+
+ fn ensure_dir(&self) -> Result {
+ let dir = self.workspace_dir.join(THREAD_GOALS_DIR);
+ fs::create_dir_all(&dir)
+ .map_err(|e| format!("create thread goals dir {}: {e}", dir.display()))?;
+ Ok(dir)
+ }
+
+ fn goal_path(&self, thread_id: &str) -> Result {
+ let thread_id = validate_thread_id(thread_id)?;
+ Ok(self.workspace_dir.join(THREAD_GOALS_DIR).join(format!(
+ "{}.{}",
+ hex::encode(thread_id.as_bytes()),
+ THREAD_GOALS_EXTENSION
+ )))
+ }
+
+ /// Load the goal for `thread_id`, or `None` if the thread has no goal yet.
+ pub fn get(&self, thread_id: &str) -> Result, String> {
+ let thread_id = validate_thread_id(thread_id)?;
+ tracing::debug!(thread_id = %thread_id, "[thread_goals] get entry");
+ let path = self.goal_path(&thread_id)?;
+ if !path.exists() {
+ tracing::debug!(thread_id = %thread_id, "[thread_goals] get not_found");
+ return Ok(None);
+ }
+ let mut buf = String::new();
+ fs::File::open(&path)
+ .map_err(|e| format!("open thread goal {}: {e}", path.display()))?
+ .read_to_string(&mut buf)
+ .map_err(|e| format!("read thread goal {}: {e}", path.display()))?;
+ let goal = serde_json::from_str::(&buf).map_err(|e| {
+ format!(
+ "parse thread goal {} for thread '{}': {e}",
+ path.display(),
+ thread_id
+ )
+ })?;
+ tracing::debug!(
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ status = goal.status.as_str(),
+ "[thread_goals] get ok"
+ );
+ Ok(Some(goal))
+ }
+
+ /// Persist `goal` atomically (temp file + fsync + rename), mirroring the
+ /// task board's durable write path.
+ fn put(&self, goal: &ThreadGoal) -> Result<(), String> {
+ let thread_id = validate_thread_id(&goal.thread_id)?;
+ let dir = self.ensure_dir()?;
+ let path = self.goal_path(&thread_id)?;
+ let mut tmp = tempfile::NamedTempFile::new_in(&dir)
+ .map_err(|e| format!("create thread goal tempfile in {}: {e}", dir.display()))?;
+ let bytes =
+ serde_json::to_vec_pretty(goal).map_err(|e| format!("serialize thread goal: {e}"))?;
+ tmp.write_all(&bytes)
+ .map_err(|e| format!("write thread goal tempfile: {e}"))?;
+ tmp.as_file()
+ .sync_all()
+ .map_err(|e| format!("fsync thread goal tempfile: {e}"))?;
+ tmp.persist(&path)
+ .map_err(|e| format!("persist thread goal {}: {e}", path.display()))?;
+ tracing::debug!(
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ status = goal.status.as_str(),
+ tokens_used = goal.tokens_used,
+ "[thread_goals] put ok"
+ );
+ Ok(())
+ }
+
+ /// Delete the goal for `thread_id`. Returns whether a file was removed.
+ fn delete_file(&self, thread_id: &str) -> Result {
+ let path = self.goal_path(thread_id)?;
+ if !path.exists() {
+ return Ok(false);
+ }
+ fs::remove_file(&path)
+ .map_err(|e| format!("delete thread goal {}: {e}", path.display()))?;
+ Ok(true)
+ }
+}
+
+// ── Async mutation API (mutex-guarded load→mutate→save) ──────────────────────
+
+/// Create or replace the goal for `thread_id`.
+///
+/// Codex semantics: if `objective` **differs** from the current one, a fresh
+/// `goal_id` is minted and the usage counters reset (status → `Active`). If the
+/// objective is **unchanged**, counters and `goal_id` are preserved and only
+/// the budget / `updated_at` are refreshed (status returns to `Active` so a
+/// completed/paused goal can be explicitly re-opened by re-setting it).
+pub async fn set(
+ workspace_dir: &Path,
+ thread_id: &str,
+ objective: &str,
+ token_budget: Option,
+) -> Result {
+ let objective = objective.trim();
+ if objective.is_empty() {
+ return Err("thread goal objective must not be empty".to_string());
+ }
+ let thread_id = validate_thread_id(thread_id)?;
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ let goal = compute_and_put_set(&store, &thread_id, objective, token_budget)?;
+ tracing::info!(
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ "[thread_goals] set objective ({} chars), budget={:?}",
+ goal.objective.chars().count(),
+ goal.token_budget
+ );
+ Ok(goal)
+}
+
+/// Build + persist the goal for a `set`. The caller MUST hold
+/// [`goal_mutation_lock`] so the read-modify-write is atomic.
+fn compute_and_put_set(
+ store: &ThreadGoalStore,
+ thread_id: &str,
+ objective: &str,
+ token_budget: Option,
+) -> Result {
+ let now = now_ms();
+ let goal = match store.get(thread_id)? {
+ Some(mut existing) if existing.objective == objective => {
+ // Same objective → preserve counters/goal_id; refresh budget.
+ existing.token_budget = token_budget;
+ existing.continuation_suppressed = false;
+ existing.updated_at_ms = now;
+ // Re-open to Active, but don't un-limit a goal that is still over its
+ // (possibly updated) budget — counters are only reset by a *changed*
+ // objective, so a same-objective re-set must stay budget_limited.
+ existing.status = if existing.over_budget() {
+ ThreadGoalStatus::BudgetLimited
+ } else {
+ ThreadGoalStatus::Active
+ };
+ existing
+ }
+ existing => {
+ // New / changed objective → fresh goal_id, reset counters.
+ let created_at_ms = existing.as_ref().map(|g| g.created_at_ms).unwrap_or(now);
+ ThreadGoal {
+ thread_id: thread_id.to_string(),
+ goal_id: uuid::Uuid::new_v4().to_string(),
+ objective: objective.to_string(),
+ status: ThreadGoalStatus::Active,
+ token_budget,
+ tokens_used: 0,
+ time_used_seconds: 0,
+ created_at_ms,
+ updated_at_ms: now,
+ continuation_suppressed: false,
+ }
+ }
+ };
+ store.put(&goal)?;
+ Ok(goal)
+}
+
+/// Set the goal **only if the thread has none yet**. Returns `Some(goal)` when a
+/// new goal was created, or `None` when a goal already existed (left untouched).
+///
+/// This backs the "scout proposes if empty, orchestrator authoritative"
+/// precedence: the context-gathering path may bootstrap a goal on the first
+/// turn but must never clobber a user/orchestrator-refined one. The check and
+/// the write run under a single lock acquisition so a concurrent `set` /
+/// `set_if_absent` can't slip into the gap and get clobbered.
+pub async fn set_if_absent(
+ workspace_dir: &Path,
+ thread_id: &str,
+ objective: &str,
+ token_budget: Option,
+) -> Result, String> {
+ let objective = objective.trim();
+ if objective.is_empty() {
+ return Err("thread goal objective must not be empty".to_string());
+ }
+ let thread_id = validate_thread_id(thread_id)?;
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ if store.get(&thread_id)?.is_some() {
+ tracing::debug!(thread_id = %thread_id, "[thread_goals] set_if_absent skipped (exists)");
+ return Ok(None);
+ }
+ let goal = compute_and_put_set(&store, &thread_id, objective, token_budget)?;
+ Ok(Some(goal))
+}
+
+/// Load the goal for `thread_id` (read-only), or `None`.
+pub async fn get(workspace_dir: &Path, thread_id: &str) -> Result , String> {
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ store.get(thread_id)
+}
+
+/// Load every persisted thread goal (read-only). Skips files that fail to parse
+/// (logged) so one corrupt entry can't hide the rest. Used by the heartbeat
+/// continuation runtime to find idle candidates.
+pub async fn list_all(workspace_dir: &Path) -> Result, String> {
+ let dir = workspace_dir.join(THREAD_GOALS_DIR);
+ let mut entries = match tokio::fs::read_dir(&dir).await {
+ Ok(rd) => rd,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
+ Err(e) => return Err(format!("read thread goals dir {}: {e}", dir.display())),
+ };
+ let mut goals = Vec::new();
+ while let Some(entry) = entries
+ .next_entry()
+ .await
+ .map_err(|e| format!("iterate thread goals dir: {e}"))?
+ {
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) != Some(THREAD_GOALS_EXTENSION) {
+ continue;
+ }
+ match tokio::fs::read_to_string(&path).await {
+ Ok(body) => match serde_json::from_str::(&body) {
+ Ok(goal) => goals.push(goal),
+ Err(e) => {
+ tracing::debug!(path = %path.display(), error = %e, "[thread_goals] list_all skip parse error");
+ }
+ },
+ Err(e) => {
+ tracing::debug!(path = %path.display(), error = %e, "[thread_goals] list_all skip read error");
+ }
+ }
+ }
+ Ok(goals)
+}
+
+/// Delete the goal for `thread_id`. Returns whether one existed.
+pub async fn clear(workspace_dir: &Path, thread_id: &str) -> Result {
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ let removed = store.delete_file(thread_id)?;
+ tracing::info!(thread_id = %thread_id, removed, "[thread_goals] clear");
+ Ok(removed)
+}
+
+/// Generic guarded mutator: load, apply `f`, persist. Returns the updated goal,
+/// or an error if the thread has no goal.
+async fn mutate(workspace_dir: &Path, thread_id: &str, f: F) -> Result
+where
+ F: FnOnce(&mut ThreadGoal),
+{
+ let thread_id = validate_thread_id(thread_id)?;
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ let mut goal = store
+ .get(&thread_id)?
+ .ok_or_else(|| format!("no thread goal for thread '{thread_id}'"))?;
+ f(&mut goal);
+ goal.updated_at_ms = now_ms();
+ store.put(&goal)?;
+ Ok(goal)
+}
+
+/// Mark the goal `Complete` (model-driven success).
+pub async fn complete(workspace_dir: &Path, thread_id: &str) -> Result {
+ let goal = mutate(workspace_dir, thread_id, |g| {
+ g.status = ThreadGoalStatus::Complete;
+ g.continuation_suppressed = true;
+ })
+ .await?;
+ tracing::info!(thread_id = %thread_id, goal_id = %goal.goal_id, "[thread_goals] complete");
+ Ok(goal)
+}
+
+/// Pause an `Active` goal (system-driven, on interrupt/abort). A no-op for goals
+/// that aren't currently active.
+pub async fn pause(workspace_dir: &Path, thread_id: &str) -> Result {
+ let goal = mutate(workspace_dir, thread_id, |g| {
+ if g.status.is_active() {
+ g.status = ThreadGoalStatus::Paused;
+ }
+ })
+ .await?;
+ tracing::info!(thread_id = %thread_id, status = goal.status.as_str(), "[thread_goals] pause");
+ Ok(goal)
+}
+
+/// Resume a `Paused` goal (system-driven, on thread resume). A no-op for goals
+/// that aren't paused (a completed/budget-limited goal is not reactivated).
+pub async fn resume(workspace_dir: &Path, thread_id: &str) -> Result {
+ let goal = mutate(workspace_dir, thread_id, |g| {
+ if matches!(g.status, ThreadGoalStatus::Paused) {
+ g.status = ThreadGoalStatus::Active;
+ g.continuation_suppressed = false;
+ }
+ })
+ .await?;
+ tracing::info!(thread_id = %thread_id, status = goal.status.as_str(), "[thread_goals] resume");
+ Ok(goal)
+}
+
+/// Set or clear the `continuation_suppressed` flag (idle-continuation guard).
+pub async fn set_continuation_suppressed(
+ workspace_dir: &Path,
+ thread_id: &str,
+ suppressed: bool,
+) -> Result {
+ mutate(workspace_dir, thread_id, |g| {
+ g.continuation_suppressed = suppressed;
+ })
+ .await
+}
+
+/// Set `continuation_suppressed` only when the thread's current goal still
+/// matches `expected_goal_id` (compare-and-set). Returns the goal as it stands
+/// after the (possibly skipped) write, or `None` when the thread has no goal.
+///
+/// Used by the continuation runtime so a goal that was completed or replaced
+/// during the autonomous turn is never suppressed by the post-dispatch write.
+pub async fn set_continuation_suppressed_if(
+ workspace_dir: &Path,
+ thread_id: &str,
+ expected_goal_id: &str,
+ suppressed: bool,
+) -> Result, String> {
+ let thread_id = validate_thread_id(thread_id)?;
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ let Some(mut goal) = store.get(&thread_id)? else {
+ return Ok(None);
+ };
+ // Skip when the goal was replaced (goal_id mismatch), is no longer active
+ // (completed/paused/budget_limited during the turn — must not be re-touched),
+ // or is already in the requested state.
+ if goal.goal_id != expected_goal_id
+ || !goal.status.is_active()
+ || goal.continuation_suppressed == suppressed
+ {
+ return Ok(Some(goal));
+ }
+ goal.continuation_suppressed = suppressed;
+ goal.updated_at_ms = now_ms();
+ store.put(&goal)?;
+ Ok(Some(goal))
+}
+
+/// Account token + time usage against the goal, applying the budget constraint.
+///
+/// **Stale-write guard (Codex parity):** the delta is **silently ignored** when
+/// `expected_goal_id` doesn't match the current goal — an in-flight accounting
+/// call from a now-replaced goal must not corrupt the new one. Returns the goal
+/// as it stands after the (possibly skipped) update, or `None` if there is no
+/// goal for the thread.
+pub async fn account_usage(
+ workspace_dir: &Path,
+ thread_id: &str,
+ expected_goal_id: &str,
+ token_delta: u64,
+ secs_delta: u64,
+) -> Result , String> {
+ let thread_id = validate_thread_id(thread_id)?;
+ let _guard = goal_mutation_lock().lock().await;
+ let store = ThreadGoalStore::new(workspace_dir.to_path_buf());
+ let Some(mut goal) = store.get(&thread_id)? else {
+ return Ok(None);
+ };
+ if goal.goal_id != expected_goal_id {
+ tracing::debug!(
+ thread_id = %thread_id,
+ expected = %expected_goal_id,
+ actual = %goal.goal_id,
+ "[thread_goals] account_usage ignored stale goal_id"
+ );
+ return Ok(Some(goal));
+ }
+ if token_delta == 0 && secs_delta == 0 {
+ return Ok(Some(goal));
+ }
+ goal.tokens_used = goal.tokens_used.saturating_add(token_delta);
+ goal.time_used_seconds = goal.time_used_seconds.saturating_add(secs_delta);
+ // Apply the budget cap: an active goal that crosses its budget becomes
+ // budget-limited. Completed/paused goals keep their status.
+ if goal.status.is_active() && goal.over_budget() {
+ goal.status = ThreadGoalStatus::BudgetLimited;
+ tracing::info!(
+ thread_id = %thread_id,
+ goal_id = %goal.goal_id,
+ tokens_used = goal.tokens_used,
+ budget = ?goal.token_budget,
+ "[thread_goals] budget reached → budget_limited"
+ );
+ }
+ goal.updated_at_ms = now_ms();
+ store.put(&goal)?;
+ Ok(Some(goal))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ #[tokio::test]
+ async fn set_get_clear_round_trip() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ assert!(get(dir, "t1").await.unwrap().is_none());
+
+ let g = set(dir, "t1", "ship the feature", None).await.unwrap();
+ assert_eq!(g.objective, "ship the feature");
+ assert_eq!(g.status, ThreadGoalStatus::Active);
+ assert_eq!(g.tokens_used, 0);
+
+ let loaded = get(dir, "t1").await.unwrap().unwrap();
+ assert_eq!(loaded.goal_id, g.goal_id);
+
+ assert!(clear(dir, "t1").await.unwrap());
+ assert!(get(dir, "t1").await.unwrap().is_none());
+ assert!(!clear(dir, "t1").await.unwrap());
+ }
+
+ #[tokio::test]
+ async fn set_same_objective_preserves_goal_id_and_counters() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let g1 = set(dir, "t", "objective A", Some(100)).await.unwrap();
+ account_usage(dir, "t", &g1.goal_id, 30, 5)
+ .await
+ .unwrap()
+ .unwrap();
+ let g2 = set(dir, "t", "objective A", Some(200)).await.unwrap();
+ assert_eq!(g1.goal_id, g2.goal_id, "same objective keeps goal_id");
+ assert_eq!(g2.tokens_used, 30, "counters preserved");
+ assert_eq!(g2.token_budget, Some(200), "budget refreshed");
+ }
+
+ #[tokio::test]
+ async fn set_same_objective_stays_budget_limited_when_over_budget() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let g = set(dir, "t", "obj", Some(100)).await.unwrap();
+ // Burn past the budget → BudgetLimited.
+ let limited = account_usage(dir, "t", &g.goal_id, 120, 0)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(limited.status, ThreadGoalStatus::BudgetLimited);
+ // Re-setting the SAME objective preserves counters, so it must NOT
+ // silently re-activate while still over budget.
+ let resed = set(dir, "t", "obj", Some(100)).await.unwrap();
+ assert_eq!(resed.tokens_used, 120, "same-objective preserves counters");
+ assert_eq!(
+ resed.status,
+ ThreadGoalStatus::BudgetLimited,
+ "still over budget → must stay budget_limited, not active"
+ );
+ // Raising the budget above usage re-opens it.
+ let raised = set(dir, "t", "obj", Some(1000)).await.unwrap();
+ assert_eq!(raised.status, ThreadGoalStatus::Active);
+ }
+
+ #[tokio::test]
+ async fn set_changed_objective_mints_new_goal_id_and_resets() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let g1 = set(dir, "t", "objective A", None).await.unwrap();
+ account_usage(dir, "t", &g1.goal_id, 30, 5)
+ .await
+ .unwrap()
+ .unwrap();
+ let g2 = set(dir, "t", "objective B", None).await.unwrap();
+ assert_ne!(g1.goal_id, g2.goal_id);
+ assert_eq!(g2.tokens_used, 0, "counters reset on new objective");
+ assert_eq!(g2.created_at_ms, g1.created_at_ms, "created_at preserved");
+ }
+
+ #[tokio::test]
+ async fn set_if_absent_only_bootstraps_when_empty() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let created = set_if_absent(dir, "t", "scout goal", None).await.unwrap();
+ assert!(created.is_some());
+ // Second call must not clobber.
+ let again = set_if_absent(dir, "t", "different goal", None)
+ .await
+ .unwrap();
+ assert!(again.is_none());
+ assert_eq!(
+ get(dir, "t").await.unwrap().unwrap().objective,
+ "scout goal"
+ );
+ }
+
+ #[tokio::test]
+ async fn account_usage_ignores_stale_goal_id() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let g = set(dir, "t", "obj", None).await.unwrap();
+ // Stale id → ignored, counters unchanged.
+ let after = account_usage(dir, "t", "not-the-goal-id", 50, 1)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(after.tokens_used, 0);
+ // Correct id → applied.
+ let after = account_usage(dir, "t", &g.goal_id, 50, 1)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(after.tokens_used, 50);
+ }
+
+ #[tokio::test]
+ async fn account_usage_trips_budget_limited() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ let g = set(dir, "t", "obj", Some(100)).await.unwrap();
+ let after = account_usage(dir, "t", &g.goal_id, 120, 2)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(after.status, ThreadGoalStatus::BudgetLimited);
+ assert_eq!(after.budget_remaining(), Some(0));
+ }
+
+ #[tokio::test]
+ async fn pause_resume_complete_transitions() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ set(dir, "t", "obj", None).await.unwrap();
+ assert_eq!(
+ pause(dir, "t").await.unwrap().status,
+ ThreadGoalStatus::Paused
+ );
+ assert_eq!(
+ resume(dir, "t").await.unwrap().status,
+ ThreadGoalStatus::Active
+ );
+ let done = complete(dir, "t").await.unwrap();
+ assert_eq!(done.status, ThreadGoalStatus::Complete);
+ // Resume does not reactivate a completed goal.
+ assert_eq!(
+ resume(dir, "t").await.unwrap().status,
+ ThreadGoalStatus::Complete
+ );
+ }
+
+ #[tokio::test]
+ async fn mutators_error_without_a_goal() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ assert!(complete(dir, "missing").await.is_err());
+ assert!(pause(dir, "missing").await.is_err());
+ }
+
+ #[tokio::test]
+ async fn empty_objective_and_blank_thread_id_rejected() {
+ let tmp = tempdir().unwrap();
+ let dir = tmp.path();
+ assert!(set(dir, "t", " ", None).await.is_err());
+ assert!(set(dir, " ", "obj", None).await.is_err());
+ }
+}
diff --git a/src/openhuman/thread_goals/tools.rs b/src/openhuman/thread_goals/tools.rs
new file mode 100644
index 000000000..73fc4149c
--- /dev/null
+++ b/src/openhuman/thread_goals/tools.rs
@@ -0,0 +1,275 @@
+//! Agent-facing tools for the thread-level goal.
+//!
+//! These let the orchestrator (and any agent that allowlists them) read and
+//! drive the current thread's goal. Ownership is **asymmetric** (Codex parity):
+//! the model may create/replace the goal (`goal_set`), read it (`goal_get`), and
+//! mark it complete (`goal_complete`). Pause / resume / budget-limit are
+//! system-driven and have no model tool.
+//!
+//! The target thread is resolved from the ambient
+//! [`current_thread_id`](crate::openhuman::inference::provider::thread_context::current_thread_id)
+//! task-local set by the chat channel — tools never take a `thread_id` arg, so
+//! the model can't address another thread's goal. Each tool is sandboxed to a
+//! single `workspace_dir` captured at construction.
+
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use serde_json::json;
+
+use super::store;
+use super::types::ThreadGoal;
+use crate::openhuman::inference::provider::thread_context::current_thread_id;
+use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
+
+/// Render a goal as a compact, model-readable block.
+fn render_goal(goal: &ThreadGoal) -> String {
+ let budget = match (goal.token_budget, goal.budget_remaining()) {
+ (Some(b), Some(rem)) => format!("{} used / {b} budget ({rem} left)", goal.tokens_used),
+ _ => format!("{} used / no budget", goal.tokens_used),
+ };
+ format!(
+ "[thread_goal]\nstatus: {}\nobjective: {}\ntokens: {budget}\n[/thread_goal]",
+ goal.status.as_str(),
+ goal.objective
+ )
+}
+
+/// Resolve the ambient thread id or return a uniform tool error.
+fn require_thread_id() -> Result {
+ current_thread_id().ok_or_else(|| {
+ ToolResult::error(
+ "thread goal tools require an active chat thread (no ambient thread_id in this context)",
+ )
+ })
+}
+
+/// `goal_get` — read the current thread goal.
+pub struct GoalGetTool {
+ workspace_dir: PathBuf,
+}
+
+impl GoalGetTool {
+ pub fn new(workspace_dir: PathBuf) -> Self {
+ Self { workspace_dir }
+ }
+}
+
+#[async_trait]
+impl Tool for GoalGetTool {
+ fn name(&self) -> &str {
+ "goal_get"
+ }
+
+ fn description(&self) -> &str {
+ "Read this thread's goal — the durable objective you're pursuing across \
+ turns — with its status (active/paused/budget_limited/complete) and token \
+ usage. Returns 'no goal set' when the thread has none."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ json!({ "type": "object", "properties": {} })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::ReadOnly
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> anyhow::Result {
+ let thread_id = match require_thread_id() {
+ Ok(id) => id,
+ Err(e) => return Ok(e),
+ };
+ log::debug!("[thread_goals] tool=goal_get thread_id={thread_id}");
+ match store::get(&self.workspace_dir, &thread_id).await {
+ Ok(Some(goal)) => Ok(ToolResult::success(render_goal(&goal))),
+ Ok(None) => Ok(ToolResult::success("no goal set for this thread")),
+ Err(e) => Ok(ToolResult::error(e)),
+ }
+ }
+}
+
+/// `goal_set` — create or replace this thread's goal.
+pub struct GoalSetTool {
+ workspace_dir: PathBuf,
+}
+
+impl GoalSetTool {
+ pub fn new(workspace_dir: PathBuf) -> Self {
+ Self { workspace_dir }
+ }
+}
+
+#[async_trait]
+impl Tool for GoalSetTool {
+ fn name(&self) -> &str {
+ "goal_set"
+ }
+
+ fn description(&self) -> &str {
+ "Set (or replace) this thread's goal — the durable objective you should \
+ keep pursuing across turns until it's complete. Use at the start of a \
+ non-trivial request, or to refine the objective as it sharpens. Changing \
+ the objective resets usage counters. Optionally set a token_budget; when \
+ reached, the goal pauses with a progress summary."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ json!({
+ "type": "object",
+ "required": ["objective"],
+ "properties": {
+ "objective": {
+ "type": "string",
+ "description": "The durable objective — what 'done' looks like for this thread."
+ },
+ "token_budget": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Optional token ceiling for the goal. Omit for no limit."
+ }
+ }
+ })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::Write
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result {
+ let thread_id = match require_thread_id() {
+ Ok(id) => id,
+ Err(e) => return Ok(e),
+ };
+ let Some(objective) = args.get("objective").and_then(|v| v.as_str()) else {
+ return Ok(ToolResult::error("Missing 'objective' parameter"));
+ };
+ let token_budget = args.get("token_budget").and_then(|v| v.as_u64());
+ log::debug!("[thread_goals] tool=goal_set thread_id={thread_id} budget={token_budget:?}");
+ match store::set(&self.workspace_dir, &thread_id, objective, token_budget).await {
+ Ok(goal) => {
+ // Emit the live-update event so the UI chip refreshes immediately.
+ crate::core::event_bus::publish_global(
+ crate::core::event_bus::DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ },
+ );
+ Ok(ToolResult::success(format!(
+ "Goal set.\n{}",
+ render_goal(&goal)
+ )))
+ }
+ Err(e) => Ok(ToolResult::error(e)),
+ }
+ }
+}
+
+/// `goal_complete` — mark this thread's goal complete (evidence-backed success).
+pub struct GoalCompleteTool {
+ workspace_dir: PathBuf,
+}
+
+impl GoalCompleteTool {
+ pub fn new(workspace_dir: PathBuf) -> Self {
+ Self { workspace_dir }
+ }
+}
+
+#[async_trait]
+impl Tool for GoalCompleteTool {
+ fn name(&self) -> &str {
+ "goal_complete"
+ }
+
+ fn description(&self) -> &str {
+ "Mark this thread's goal complete. Only call this when concrete evidence \
+ confirms the objective is satisfied — completing stops autonomous \
+ continuation."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ json!({ "type": "object", "properties": {} })
+ }
+
+ fn permission_level(&self) -> PermissionLevel {
+ PermissionLevel::Write
+ }
+
+ async fn execute(&self, _args: serde_json::Value) -> anyhow::Result {
+ let thread_id = match require_thread_id() {
+ Ok(id) => id,
+ Err(e) => return Ok(e),
+ };
+ log::debug!("[thread_goals] tool=goal_complete thread_id={thread_id}");
+ match store::complete(&self.workspace_dir, &thread_id).await {
+ Ok(goal) => {
+ crate::core::event_bus::publish_global(
+ crate::core::event_bus::DomainEvent::ThreadGoalUpdated {
+ thread_id: goal.thread_id.clone(),
+ goal_id: goal.goal_id.clone(),
+ status: goal.status.as_str().to_string(),
+ },
+ );
+ Ok(ToolResult::success(format!(
+ "Goal marked complete.\n{}",
+ render_goal(&goal)
+ )))
+ }
+ Err(e) => Ok(ToolResult::error(e)),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::inference::provider::thread_context::with_thread_id;
+
+ #[tokio::test]
+ async fn set_get_complete_via_tools_in_thread_scope() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ with_thread_id("thread-tools", async {
+ let set = GoalSetTool::new(dir.clone());
+ let res = set
+ .execute(json!({ "objective": "land the PR", "token_budget": 5000 }))
+ .await
+ .unwrap();
+ assert!(!res.is_error, "{}", res.text());
+ assert!(res.text().contains("land the PR"));
+
+ let get = GoalGetTool::new(dir.clone());
+ let res = get.execute(json!({})).await.unwrap();
+ assert!(res.text().contains("status: active"));
+
+ let done = GoalCompleteTool::new(dir.clone());
+ let res = done.execute(json!({})).await.unwrap();
+ assert!(res.text().contains("status: complete"));
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn tools_error_without_thread_scope() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ let set = GoalSetTool::new(dir.clone());
+ let res = set.execute(json!({ "objective": "x" })).await.unwrap();
+ assert!(res.is_error);
+ assert!(res.text().contains("active chat thread"));
+ }
+
+ #[tokio::test]
+ async fn get_reports_absent_goal() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().to_path_buf();
+ with_thread_id("empty-thread", async {
+ let get = GoalGetTool::new(dir.clone());
+ let res = get.execute(json!({})).await.unwrap();
+ assert!(res.text().contains("no goal set"));
+ })
+ .await;
+ }
+}
diff --git a/src/openhuman/thread_goals/types.rs b/src/openhuman/thread_goals/types.rs
new file mode 100644
index 000000000..b828ef7ba
--- /dev/null
+++ b/src/openhuman/thread_goals/types.rs
@@ -0,0 +1,151 @@
+//! Domain types for the thread-level goal.
+//!
+//! A **thread goal** is a single, thread-scoped "completion contract" — a
+//! durable objective the agent keeps pursuing across turns, interrupts,
+//! resumes, and budget boundaries. It is distinct from the global
+//! [`memory_goals`](crate::openhuman::memory_goals) list (long-term, workspace
+//! wide) and from the per-thread kanban task board: there is **exactly one**
+//! goal per thread, with a small lifecycle and optional token budget.
+//!
+//! The shape mirrors OpenAI Codex's `thread_goals` row, adapted to OpenHuman's
+//! per-thread file-JSON persistence (see [`super::store`]).
+
+use serde::{Deserialize, Serialize};
+
+/// Lifecycle state of a thread goal.
+///
+/// Ownership is **asymmetric** (Codex parity): the model may create/replace a
+/// goal and mark it `Complete`; `Paused` / `BudgetLimited` are system-driven
+/// (interrupt/abort and accounting respectively), and clearing deletes the row
+/// entirely rather than being a status.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ThreadGoalStatus {
+ /// The agent may make progress and (when idle) auto-continue.
+ Active,
+ /// Work is suspended (user interrupt/abort); the objective persists and is
+ /// reactivated on thread resume.
+ Paused,
+ /// The token budget has been reached; substantive work halts until the user
+ /// raises the budget or clears the goal.
+ BudgetLimited,
+ /// Evidence confirms the objective is satisfied.
+ Complete,
+}
+
+impl ThreadGoalStatus {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Active => "active",
+ Self::Paused => "paused",
+ Self::BudgetLimited => "budget_limited",
+ Self::Complete => "complete",
+ }
+ }
+
+ /// Whether the goal is in a state where the agent should keep working it
+ /// (and idle auto-continuation may fire).
+ pub fn is_active(&self) -> bool {
+ matches!(self, Self::Active)
+ }
+
+ /// Whether the goal is in a terminal state for continuation purposes —
+ /// `Complete` or `BudgetLimited` never auto-continue.
+ pub fn is_terminal(&self) -> bool {
+ matches!(self, Self::Complete | Self::BudgetLimited)
+ }
+}
+
+/// A single thread-scoped goal.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThreadGoal {
+ /// The thread this goal belongs to (one goal per thread).
+ pub thread_id: String,
+ /// Version identifier, re-minted on **every objective replacement**. Stale
+ /// accounting writes that pass a non-matching `expected_goal_id` are
+ /// silently ignored — see [`super::store::account_usage`].
+ pub goal_id: String,
+ /// The durable objective, one or more sentences.
+ pub objective: String,
+ /// Lifecycle state.
+ pub status: ThreadGoalStatus,
+ /// Optional token ceiling. When set and `tokens_used >= token_budget`, the
+ /// goal transitions to [`ThreadGoalStatus::BudgetLimited`].
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub token_budget: Option,
+ /// Cumulative tokens accounted against this goal.
+ #[serde(default)]
+ pub tokens_used: u64,
+ /// Cumulative wall-clock seconds accounted against this goal.
+ #[serde(default)]
+ pub time_used_seconds: u64,
+ /// Creation time (unix epoch milliseconds).
+ pub created_at_ms: u64,
+ /// Last-mutation time (unix epoch milliseconds).
+ pub updated_at_ms: u64,
+ /// Set when an idle auto-continuation turn produced **zero tool calls**, to
+ /// stop a continuation loop. Cleared on any user action, tool execution, or
+ /// external mutation (e.g. `goal_set`).
+ #[serde(default)]
+ pub continuation_suppressed: bool,
+}
+
+impl ThreadGoal {
+ /// Tokens remaining before the budget cap, if a budget is set.
+ pub fn budget_remaining(&self) -> Option {
+ self.token_budget
+ .map(|b| b.saturating_sub(self.tokens_used))
+ }
+
+ /// Whether accounting has reached or exceeded the configured budget.
+ pub fn over_budget(&self) -> bool {
+ matches!(self.token_budget, Some(b) if self.tokens_used >= b)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn status_strings_match_serialized() {
+ assert_eq!(ThreadGoalStatus::Active.as_str(), "active");
+ assert_eq!(ThreadGoalStatus::Paused.as_str(), "paused");
+ assert_eq!(ThreadGoalStatus::BudgetLimited.as_str(), "budget_limited");
+ assert_eq!(ThreadGoalStatus::Complete.as_str(), "complete");
+ }
+
+ #[test]
+ fn active_and_terminal_predicates() {
+ assert!(ThreadGoalStatus::Active.is_active());
+ assert!(!ThreadGoalStatus::Paused.is_active());
+ assert!(ThreadGoalStatus::Complete.is_terminal());
+ assert!(ThreadGoalStatus::BudgetLimited.is_terminal());
+ assert!(!ThreadGoalStatus::Active.is_terminal());
+ }
+
+ #[test]
+ fn budget_helpers() {
+ let mut g = ThreadGoal {
+ thread_id: "t".into(),
+ goal_id: "g".into(),
+ objective: "do it".into(),
+ status: ThreadGoalStatus::Active,
+ token_budget: Some(100),
+ tokens_used: 40,
+ time_used_seconds: 0,
+ created_at_ms: 0,
+ updated_at_ms: 0,
+ continuation_suppressed: false,
+ };
+ assert_eq!(g.budget_remaining(), Some(60));
+ assert!(!g.over_budget());
+ g.tokens_used = 120;
+ assert_eq!(g.budget_remaining(), Some(0));
+ assert!(g.over_budget());
+ g.token_budget = None;
+ assert_eq!(g.budget_remaining(), None);
+ assert!(!g.over_budget());
+ }
+}
diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs
index 4347683a9..763698f43 100644
--- a/src/openhuman/tools/ops.rs
+++ b/src/openhuman/tools/ops.rs
@@ -576,6 +576,24 @@ pub fn all_tools_with_runtime(
));
}
+ // Thread-level goal tools (Codex-style per-thread completion contract).
+ // Visible only to agents that allowlist them (orchestrator). The target
+ // thread is resolved from the ambient `thread_id`, so no thread arg is
+ // taken. `goal_get`/`goal_set`/`goal_complete` — pause/resume/budget are
+ // system-driven and have no model tool.
+ {
+ let goal_dir = root_config.workspace_dir.clone();
+ tools.push(Box::new(crate::openhuman::thread_goals::GoalGetTool::new(
+ goal_dir.clone(),
+ )));
+ tools.push(Box::new(crate::openhuman::thread_goals::GoalSetTool::new(
+ goal_dir.clone(),
+ )));
+ tools.push(Box::new(
+ crate::openhuman::thread_goals::GoalCompleteTool::new(goal_dir),
+ ));
+ }
+
if browser_config.enabled {
// Unified web-access allowlist (merge fetch + browser firewalls): the
// browser tool shares the single `http_request.allowed_domains` host
diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs
index 4a2d79bda..eb4159e02 100644
--- a/tests/agent_harness_e2e.rs
+++ b/tests/agent_harness_e2e.rs
@@ -294,6 +294,12 @@ chat_onboarding_completed = true
[secrets]
encrypt = false
+
+[context]
+# These harness tests script the mock-LLM call sequence exactly; the default-on
+# first-turn "super context" pass (#4085) would spawn a context_scout and consume
+# a scripted response, desyncing the orchestrator turns. Disable it here.
+super_context_enabled = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
@@ -2387,7 +2393,14 @@ mod streaming_support {
.event_context("stream-accum-session", "stream-accum-channel")
.agent_definition_name("round17/orchestrator")
.config(config)
- .context_config(ContextConfig::default())
+ // These are deterministic scripted-mock orchestrator turns. The
+ // default-on first-turn "super context" pass (#4085) would spawn a
+ // context_scout and add an extra model call the scripts don't expect,
+ // breaking every orchestrator test here. Disable it for the harness.
+ .context_config(ContextConfig {
+ super_context_enabled: false,
+ ..ContextConfig::default()
+ })
.auto_save(true)
.explicit_preferences_enabled(false)
.unified_compaction_enabled(false)
diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs
index 437a62c70..6b73d977a 100644
--- a/tests/json_rpc_e2e.rs
+++ b/tests/json_rpc_e2e.rs
@@ -2519,6 +2519,163 @@ async fn json_rpc_todos_crud_on_personal_board() {
rpc_join.abort();
}
+#[tokio::test]
+async fn json_rpc_thread_goal_lifecycle() {
+ let _env_lock = json_rpc_e2e_env_lock();
+ let tmp = tempdir().expect("tempdir");
+ let home = tmp.path();
+ let openhuman_home = home.join(".openhuman");
+
+ let _home_guard = EnvVarGuard::set_to_path("HOME", home);
+ let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
+ let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
+ let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
+ let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
+
+ let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
+ let api_origin = format!("http://{api_addr}");
+ write_min_config(openhuman_home.as_path(), &api_origin);
+
+ let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
+ let rpc_base = format!("http://{rpc_addr}");
+
+ // `thread_goals_*` handlers never require the thread to exist (mirrors
+ // `todos_*`), so a synthetic thread id is fine.
+ let thread = "thread-goal-e2e";
+
+ // Pull the `{ goal }` payload out of either response shape: a bare
+ // `{ goal }` (get) or the `{ result: { goal }, logs }` CLI envelope
+ // (mutations).
+ fn goal_of<'a>(result: &'a Value) -> Option<&'a Value> {
+ let envelope = result.get("result").unwrap_or(result);
+ envelope.get("goal").filter(|g| !g.is_null())
+ }
+
+ // 1. No goal yet.
+ let got = post_json_rpc(
+ &rpc_base,
+ 9201,
+ "openhuman.thread_goals_get",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert!(
+ goal_of(assert_no_jsonrpc_error(&got, "thread_goals_get")).is_none(),
+ "no goal initially"
+ );
+
+ // 2. Set with a token budget.
+ let set = post_json_rpc(
+ &rpc_base,
+ 9202,
+ "openhuman.thread_goals_set",
+ json!({ "thread_id": thread, "objective": "Ship the release", "token_budget": 5000 }),
+ )
+ .await;
+ let goal = goal_of(assert_no_jsonrpc_error(&set, "thread_goals_set")).expect("goal after set");
+ let goal_id = goal
+ .get("goalId")
+ .and_then(Value::as_str)
+ .expect("goalId")
+ .to_string();
+ assert_eq!(
+ goal.get("objective").and_then(Value::as_str),
+ Some("Ship the release")
+ );
+ assert_eq!(goal.get("status").and_then(Value::as_str), Some("active"));
+ assert_eq!(goal.get("tokenBudget").and_then(Value::as_u64), Some(5000));
+
+ // 3. Get reflects the persisted goal (same goal id).
+ let got2 = post_json_rpc(
+ &rpc_base,
+ 9203,
+ "openhuman.thread_goals_get",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert_eq!(
+ goal_of(assert_no_jsonrpc_error(&got2, "thread_goals_get#2"))
+ .and_then(|g| g.get("goalId"))
+ .and_then(Value::as_str),
+ Some(goal_id.as_str())
+ );
+
+ // 4. Pause then resume (system-driven lifecycle).
+ let paused = post_json_rpc(
+ &rpc_base,
+ 9204,
+ "openhuman.thread_goals_pause",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert_eq!(
+ goal_of(assert_no_jsonrpc_error(&paused, "pause"))
+ .and_then(|g| g.get("status"))
+ .and_then(Value::as_str),
+ Some("paused")
+ );
+ let resumed = post_json_rpc(
+ &rpc_base,
+ 9205,
+ "openhuman.thread_goals_resume",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert_eq!(
+ goal_of(assert_no_jsonrpc_error(&resumed, "resume"))
+ .and_then(|g| g.get("status"))
+ .and_then(Value::as_str),
+ Some("active")
+ );
+
+ // 5. Complete.
+ let done = post_json_rpc(
+ &rpc_base,
+ 9206,
+ "openhuman.thread_goals_complete",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert_eq!(
+ goal_of(assert_no_jsonrpc_error(&done, "complete"))
+ .and_then(|g| g.get("status"))
+ .and_then(Value::as_str),
+ Some("complete")
+ );
+
+ // 6. Clear → removed; subsequent get is null.
+ let cleared = post_json_rpc(
+ &rpc_base,
+ 9207,
+ "openhuman.thread_goals_clear",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ let cleared_result = assert_no_jsonrpc_error(&cleared, "clear");
+ assert_eq!(
+ cleared_result
+ .get("result")
+ .and_then(|r| r.get("removed"))
+ .and_then(Value::as_bool),
+ Some(true),
+ "clear reports removal"
+ );
+ let got3 = post_json_rpc(
+ &rpc_base,
+ 9208,
+ "openhuman.thread_goals_get",
+ json!({ "thread_id": thread }),
+ )
+ .await;
+ assert!(
+ goal_of(assert_no_jsonrpc_error(&got3, "thread_goals_get#3")).is_none(),
+ "goal gone after clear"
+ );
+
+ api_join.abort();
+ rpc_join.abort();
+}
+
#[tokio::test]
async fn json_rpc_thread_title_create_and_update() {
let _env_lock = json_rpc_e2e_env_lock();